From b5060b21c223e23dcc80970c1d37a3749cf625b6 Mon Sep 17 00:00:00 2001 From: LoadChange Date: Thu, 16 Jul 2026 09:18:19 +0800 Subject: [PATCH 1/5] feat: agy,grok,copilot jsonl support --- src-tauri/Cargo.lock | 70 +++- src-tauri/Cargo.toml | 3 + src-tauri/src/antigravity.rs | 725 ++++++++++++++++++++++++++++++++++ src-tauri/src/codex.rs | 165 +------- src-tauri/src/copilot.rs | 437 ++++++++++++++++++++ src-tauri/src/exporthtml.rs | 28 +- src-tauri/src/grok.rs | 537 +++++++++++++++++++++++++ src-tauri/src/history.rs | 502 +++++++++++++++++++++-- src-tauri/src/lib.rs | 36 +- src-tauri/src/sidecar.rs | 204 ++++++++++ src-tauri/src/store.rs | 42 ++ src-tauri/src/usage.rs | 7 + src/renderer/conversations.js | 24 +- 13 files changed, 2559 insertions(+), 221 deletions(-) create mode 100644 src-tauri/src/antigravity.rs create mode 100644 src-tauri/src/copilot.rs create mode 100644 src-tauri/src/grok.rs create mode 100644 src-tauri/src/sidecar.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f098c7f..0ef1e8f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -19,6 +19,18 @@ dependencies = [ "version_check", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -93,6 +105,7 @@ dependencies = [ "regex", "reqwest 0.12.28", "rfd", + "rusqlite", "serde", "serde_json", "sha1", @@ -1458,6 +1471,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.13.0" @@ -2039,7 +2064,16 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" dependencies = [ - "ahash", + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", ] [[package]] @@ -2048,6 +2082,15 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2652,6 +2695,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4020,6 +4074,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" version = "1.42.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 912d460..3bd8a62 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,6 +43,9 @@ notify-debouncer-mini = "0.4" # Conversation-bundle ZIP (export/import subagents together). Pure-Rust miniz_oxide backend — no # system zlib, so it cross-compiles cleanly for every release target. flate2 = "1" +# Antigravity CLI conversations are per-session SQLite files (steps table). Bundled engine so +# release targets don't need a system sqlite. +rusqlite = { version = "0.32", features = ["bundled"] } # Protocol translation (chat / responses / messages互转). Reused as the IR + OpenAI/Responses # codec engine; we add only the Anthropic server-side halves (request-decode / response-encode). # default-features off to avoid pulling a second reqwest/tls stack (we use our own client); keep diff --git a/src-tauri/src/antigravity.rs b/src-tauri/src/antigravity.rs new file mode 100644 index 0000000..57599ba --- /dev/null +++ b/src-tauri/src/antigravity.rs @@ -0,0 +1,725 @@ +// Google Antigravity CLI (`agy`) session support — reads its per-conversation SQLite stores +// (`~/.gemini/antigravity-cli/conversations/.db`, `steps` table) plus the sibling +// `conversation_summaries.db` (title / preview / workspace uris — plain text), and normalizes +// them into the SAME session/message shape the renderer consumes (history::Norm). +// +// A step's `step_payload` is a protobuf blob with no published schema. A minimal wire-format +// walker recovers the stable fields (reverse-engineered against real conversations): +// #1 step type enum #4 status +// #5 metadata: #5.1 {sec,nanos} created · #5.4 tool call {#1 id, #2 name, #3 args-JSON, +// #7 result (opaque/encrypted — not recoverable)} · #5.9 generation stats +// {#2 input tokens, #3 output tokens} +// #19 user input: #19.2 text · #19.9 attachments {#1 mime, #2 bytes, #5 path} +// #20 model turn: #20.1 assistant text +// Steps whose payload drifts from this map degrade to being skipped (never crash) — the +// summaries DB alone still lists the conversation. Tool RESULTS are stored in a non-readable +// encoding, so tool cards show name/args and the renderer's "no result" marker. +// +// DBs may be WAL-journaled and open in a live agy process: connections are read-only with a +// short busy timeout, and freshness checks use max(mtime(db), mtime(db-wal)). +// +// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) +// keyed `antigravity:` — the DBs belong to another tool and are never written. + +#![allow(dead_code)] + +use crate::history::Norm; +use rusqlite::{Connection, OpenFlags}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Antigravity CLI's data dir as a history-dir entry string (`~/.gemini/antigravity-cli`). +pub fn default_root() -> PathBuf { + home().join(".gemini").join("antigravity-cli") +} + +pub fn agy_label() -> String { + crate::store::collapse_home(&default_root().to_string_lossy()) +} + +pub fn root_exists() -> bool { + default_root().join("conversations").is_dir() +} + +/// Walk every conversation DB under a `conversations/` dir. +pub fn walk(conversations_dir: &Path, cb: &mut F) { + let entries = match fs::read_dir(conversations_dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("db") { + cb(p); + } + } +} + +/// Container-shape test for detail/edit routing: `…/conversations/.db`. +pub fn looks_agy_path(file: &Path) -> bool { + file.extension().and_then(|e| e.to_str()) == Some("db") + && file + .parent() + .and_then(|d| d.file_name()) + .and_then(|n| n.to_str()) + .map(|n| n == "conversations") + .unwrap_or(false) +} + +fn session_uuid(file: &Path) -> String { + file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() +} + +fn sidecar_key(file: &Path) -> String { + format!("antigravity:{}", session_uuid(file)) +} + +fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "antigravity:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} + +/// WAL-aware freshness stamp: a live agy writes into `-wal` without touching the main +/// file's mtime, so cache keys must take the max of both. +pub fn wal_mtime_ms(file: &Path) -> f64 { + let m = |p: &Path| { + fs::metadata(p) + .and_then(|md| md.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) + }; + let mut wal = file.as_os_str().to_os_string(); + wal.push("-wal"); + m(file).max(m(Path::new(&wal))) +} + +fn open_ro(path: &Path) -> Option { + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .ok()?; + let _ = conn.busy_timeout(std::time::Duration::from_millis(400)); + Some(conn) +} + +// ---- protobuf wire walker (schema-less) ---- + +enum Wire { + Varint(u64), + Bytes(Vec), + Fixed, +} + +/// One message level → (field number, value) pairs. None when the buffer isn't a valid message. +fn wire_fields(buf: &[u8]) -> Option> { + let mut out = vec![]; + let mut i = 0usize; + fn varint(buf: &[u8], i: &mut usize) -> Option { + let mut v: u64 = 0; + let mut shift = 0u32; + loop { + let b = *buf.get(*i)?; + *i += 1; + v |= ((b & 0x7f) as u64) << shift; + if b & 0x80 == 0 { + return Some(v); + } + shift += 7; + if shift > 63 { + return None; + } + } + } + while i < buf.len() { + let tag = varint(buf, &mut i)?; + let (field, wt) = ((tag >> 3) as u32, tag & 7); + if field == 0 { + return None; + } + match wt { + 0 => out.push((field, Wire::Varint(varint(buf, &mut i)?))), + 2 => { + let len = varint(buf, &mut i)? as usize; + if i + len > buf.len() { + return None; + } + out.push((field, Wire::Bytes(buf[i..i + len].to_vec()))); + i += len; + } + 5 => { + if i + 4 > buf.len() { + return None; + } + i += 4; + out.push((field, Wire::Fixed)); + } + 1 => { + if i + 8 > buf.len() { + return None; + } + i += 8; + out.push((field, Wire::Fixed)); + } + _ => return None, + } + } + Some(out) +} + +fn field_bytes<'a>(fields: &'a [(u32, Wire)], no: u32) -> Option<&'a [u8]> { + fields.iter().find_map(|(f, w)| match w { + Wire::Bytes(b) if *f == no => Some(b.as_slice()), + _ => None, + }) +} + +fn field_msg(fields: &[(u32, Wire)], no: u32) -> Option> { + wire_fields(field_bytes(fields, no)?) +} + +fn field_str(fields: &[(u32, Wire)], no: u32) -> Option { + let b = field_bytes(fields, no)?; + let s = std::str::from_utf8(b).ok()?; + Some(s.to_string()) +} + +fn field_varint(fields: &[(u32, Wire)], no: u32) -> Option { + fields.iter().find_map(|(f, w)| match w { + Wire::Varint(v) if *f == no => Some(*v), + _ => None, + }) +} + +/// `{#1 seconds, #2 nanos}` timestamp message → RFC3339 (ms precision). +fn ts_of(fields: &[(u32, Wire)], no: u32) -> Option { + let m = field_msg(fields, no)?; + let secs = field_varint(&m, 1)? as i64; + let nanos = field_varint(&m, 2).unwrap_or(0) as u32; + let dt = chrono::DateTime::from_timestamp(secs, nanos)?; + Some(dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) +} + +fn ts_ms_of(fields: &[(u32, Wire)], no: u32) -> Option { + let m = field_msg(fields, no)?; + let secs = field_varint(&m, 1)? as f64; + let nanos = field_varint(&m, 2).unwrap_or(0) as f64; + Some(secs * 1000.0 + (nanos / 1_000_000.0).floor()) +} + +// ---- content mapping ---- + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn b64_encode(data: &[u8]) -> String { + let mut out = String::with_capacity((data.len() + 2) / 3 * 4); + for chunk in data.chunks(3) { + let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32; + out.push(B64[(n >> 18) as usize & 63] as char); + out.push(B64[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if chunk.len() > 2 { B64[n as usize & 63] as char } else { '=' }); + } + out +} + +/// Antigravity tool name + parsed arguments → (renderer tool name, renderer input). The args +/// JSON carries display strings (toolAction/toolSummary) alongside the real params — dropped +/// from generic passthrough to keep cards clean. +fn map_tool(name: &str, args: &Value) -> (String, Value) { + let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + match name { + "run_command" => { + let mut input = json!({ "command": s("CommandLine") }); + if !s("Cwd").is_empty() { + input["description"] = json!(s("Cwd")); + } + ("Bash".into(), input) + } + "view_file" => ("Read".into(), json!({ "file_path": s("AbsolutePath") })), + "list_dir" => ("LS".into(), json!({ "path": s("DirectoryPath") })), + "grep_search" => { + let mut input = json!({ "pattern": s("Query") }); + if !s("SearchPath").is_empty() { + input["path"] = json!(s("SearchPath")); + } + ("Grep".into(), input) + } + "find_by_name" => ("Glob".into(), json!({ "pattern": s("Pattern"), "path": s("SearchDirectory") })), + "replace_file_content" => ( + "Edit".into(), + json!({ "file_path": s("TargetFile"), "old_string": s("TargetContent"), "new_string": s("ReplacementContent") }), + ), + "write_to_file" => ( + "Write".into(), + json!({ "file_path": s("TargetFile"), "content": s("CodeContent") }), + ), + "read_url_content" => ("WebFetch".into(), json!({ "url": s("Url") })), + "search_web" => ("WebSearch".into(), json!({ "query": s("query") })), + _ => { + let mut input = args.clone(); + if let Some(o) = input.as_object_mut() { + o.remove("toolAction"); + o.remove("toolSummary"); + } + (name.to_string(), if input.is_object() { input } else { json!({}) }) + } + } +} + +/// Decode one step row into zero or more renderer messages, accumulating usage into `n`. +fn push_step(n: &mut Norm, payload: &[u8]) { + let fields = match wire_fields(payload) { + Some(f) => f, + None => return, + }; + let meta5 = field_msg(&fields, 5).unwrap_or_default(); + let ts = ts_of(&meta5, 1); + let with_ts = |mut m: Value| { + if let Some(t) = &ts { + m["ts"] = json!(t); + } + m + }; + + // user turn: #19 {2: text, 9: attachments {1 mime, 2 bytes, 5 path}} + if let Some(user) = field_msg(&fields, 19) { + let mut blocks: Vec = vec![]; + if let Some(text) = field_str(&user, 2) { + if !text.trim().is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + } + for (f, w) in &user { + if *f != 9 { + continue; + } + if let Wire::Bytes(b) = w { + if let Some(att) = wire_fields(b) { + let mime = field_str(&att, 1).unwrap_or_default(); + let data = field_bytes(&att, 2); + match data { + // cap embedded images at 8 MB raw — larger ones degrade to a path note + Some(bytes) if mime.starts_with("image/") && bytes.len() <= 8_000_000 => { + blocks.push(json!({ + "type": "image", + "source": { "type": "base64", "media_type": mime, "data": b64_encode(bytes) } + })); + } + _ => { + if let Some(p) = field_str(&att, 5) { + blocks.push(json!({ "type": "text", "text": format!("[attachment: {}]", p) })); + } + } + } + } + } + } + if !blocks.is_empty() { + n.messages.push(with_ts(json!({ "role": "user", "content": blocks }))); + } + return; + } + + // tool call: #5.4 {1 id, 2 name, 3 args-json} (results are stored opaquely — omitted) + if let Some(call) = field_msg(&meta5, 4) { + let name = field_str(&call, 2).unwrap_or_else(|| "tool".into()); + let args: Value = field_str(&call, 3) + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or(json!({})); + let (tname, input) = map_tool(&name, &args); + let id = field_str(&call, 1).unwrap_or_default(); + n.messages.push(with_ts(json!({ + "role": "assistant", + "content": [{ "type": "tool_use", "id": id, "name": tname, "input": input }], + }))); + return; + } + + // model turn: #20.1 assistant text; #5.9 {2 input, 3 output} token stats + let turn20 = field_msg(&fields, 20); + let text = turn20.as_ref().and_then(|t| field_str(t, 1).or_else(|| field_str(t, 8))); + let stats = field_msg(&meta5, 9); + let usage = stats.as_ref().map(|st| { + let input = field_varint(st, 2).unwrap_or(0) as i64; + let output = field_varint(st, 3).unwrap_or(0) as i64; + json!({ "inputTokens": input, "outputTokens": output, "cacheRead": 0, "cacheCreation": 0 }) + }); + if let Some(text) = text { + if !text.trim().is_empty() { + let mut m = json!({ "role": "assistant", "content": [{ "type": "text", "text": text }] }); + if let Some(u) = &usage { + let input = u.get("inputTokens").and_then(|v| v.as_i64()).unwrap_or(0); + let output = u.get("outputTokens").and_then(|v| v.as_i64()).unwrap_or(0); + if input + output > 0 { + m["usage"] = u.clone(); + let t = n.totals.as_object_mut().unwrap(); + t["in"] = json!(t["in"].as_i64().unwrap_or(0) + input); + t["out"] = json!(t["out"].as_i64().unwrap_or(0) + output); + t["turns"] = json!(t["turns"].as_i64().unwrap_or(0) + 1); + } + } + n.messages.push(with_ts(m)); + } + } +} + +/// Depth-first search for the first utf8 string field with `prefix` anywhere in a message tree. +fn find_str_with_prefix(buf: &[u8], prefix: &str, depth: u8) -> Option { + let fields = wire_fields(buf)?; + for (_, w) in &fields { + if let Wire::Bytes(b) = w { + if let Ok(s) = std::str::from_utf8(b) { + if s.starts_with(prefix) { + return Some(s.to_string()); + } + } + if depth < 6 { + if let Some(found) = find_str_with_prefix(b, prefix, depth + 1) { + return Some(found); + } + } + } + } + None +} + +fn uri_to_path(uri: &str) -> String { + crate::grok::percent_decode(uri.strip_prefix("file://").unwrap_or(uri)) +} + +/// Workspace cwd for conversations the summaries DB hasn't indexed (a few percent of real +/// stores): the per-conversation trajectory_metadata_blob embeds the workspace file:// uri. +fn fallback_cwd(file: &Path) -> Option { + let conn = open_ro(file)?; + let blob: Vec = conn + .query_row("SELECT data FROM trajectory_metadata_blob LIMIT 1", [], |r| r.get(0)) + .ok()?; + find_str_with_prefix(&blob, "file://", 0).map(|u| uri_to_path(&u)) +} + +/// Title-of-last-resort for un-indexed conversations: the first user step's prose. +fn first_user_step_text(file: &Path) -> Option { + let conn = open_ro(file)?; + let payload: Vec = conn + .query_row("SELECT step_payload FROM steps WHERE step_type = 14 ORDER BY idx LIMIT 1", [], |r| r.get(0)) + .ok()?; + let fields = wire_fields(&payload)?; + let user = field_msg(&fields, 19)?; + let text = field_str(&user, 2)?; + let t: String = text.split_whitespace().collect::>().join(" "); + if t.is_empty() { + None + } else { + Some(t.chars().take(90).collect()) + } +} + +/// One conversation's summaries-DB row: (title, preview, first workspace path, step_count). +fn summaries_row(file: &Path) -> Option<(String, String, Option, i64)> { + let root = file.parent()?.parent()?; + let conn = open_ro(&root.join("conversation_summaries.db"))?; + let uuid = session_uuid(file); + conn.query_row( + "SELECT title, preview, workspace_uris, step_count FROM conversation_summaries WHERE conversation_id = ?1", + [&uuid], + |row| { + let title: String = row.get(0).unwrap_or_default(); + let preview: String = row.get(1).unwrap_or_default(); + let uris: String = row.get(2).unwrap_or_default(); + let steps: i64 = row.get(3).unwrap_or(0); + Ok((title, preview, uris, steps)) + }, + ) + .ok() + .map(|(title, preview, uris, steps)| { + let cwd = serde_json::from_str::(&uris) + .ok() + .and_then(|v| v.as_array().and_then(|a| a.first().cloned())) + .and_then(|u| u.as_str().map(|s| s.to_string())) + .map(|u| uri_to_path(&u)); + (title, preview, cwd, steps) + }) +} + +/// Read + normalize a conversation DB into the renderer's message model. +pub fn normalize_db(file: &Path) -> Norm { + let mut n = Norm::default(); + if let Some((_, _, cwd, _)) = summaries_row(file) { + n.cwd = cwd; + } + if n.cwd.is_none() { + n.cwd = fallback_cwd(file); + } + n.session_id = Some(session_uuid(file)); + let conn = match open_ro(file) { + Some(c) => c, + None => return n, + }; + let mut stmt = match conn.prepare("SELECT step_payload FROM steps ORDER BY idx") { + Ok(s) => s, + Err(_) => return n, + }; + let rows = stmt.query_map([], |row| row.get::<_, Vec>(0)); + if let Ok(rows) = rows { + for payload in rows.flatten() { + push_step(&mut n, &payload); + } + } + n.first_ts = n.messages.first().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n.last_ts = n.messages.last().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n +} + +/// Creation stamp (ms) from the first step's timestamp — content-derived, immune to file +/// rewrites, matching record_created_ms semantics for jsonl sources. +fn first_step_ms(file: &Path) -> Option { + let conn = open_ro(file)?; + let payload: Vec = conn + .query_row("SELECT step_payload FROM steps ORDER BY idx LIMIT 1", [], |r| r.get(0)) + .ok()?; + let fields = wire_fields(&payload)?; + let meta5 = field_msg(&fields, 5)?; + ts_ms_of(&meta5, 1) +} + +/// List-row meta — summaries DB + first-step timestamp; never parses the full step log. +pub fn session_meta_from(file: &Path, dir_id: &str, dir_label: &str) -> Option { + let meta = fs::metadata(file).ok()?; + let uuid = session_uuid(file); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); + let sum = summaries_row(file); + let (sum_title, preview, cwd) = match &sum { + Some((t, p, c, _)) => (t.trim().to_string(), p.trim().to_string(), c.clone()), + None => (String::new(), String::new(), None), + }; + let cwd = cwd.or_else(|| fallback_cwd(file)); + let auto_title: String = if !sum_title.is_empty() { + sum_title + } else if !preview.is_empty() { + preview.chars().take(90).collect() + } else { + first_user_step_text(file).unwrap_or_default() + }; + let created = first_step_ms(file).unwrap_or_else(|| crate::history::created_ms(file)); + Some(json!({ + "id": format!("antigravity:{}", uuid), + "file": file.to_string_lossy(), + "source": "antigravity", + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": uuid, + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": Value::Null, + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": Value::Null, + "isSubagent": false, + "imported": false, + "deleted": cc_deleted, + "createdAt": created, + "lastActivity": wal_mtime_ms(file), + "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, + })) +} + +/// Full-detail shape (history.rs get_session routes here — the source is SQLite, not jsonl). +pub fn session_from(file: &str) -> Value { + let path = Path::new(file); + let n = normalize_db(path); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); + let sum = summaries_row(path); + let sum_title = sum + .as_ref() + .map(|(t, _, _, _)| t.trim().to_string()) + .filter(|s| !s.is_empty()); + let auto_title = sum_title.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let uuid = session_uuid(path); + json!({ + "meta": { + "id": format!("antigravity:{}", uuid), + "file": file, + "source": "antigravity", + "assistant": "Antigravity", + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": Value::Null, + "sessionId": uuid, + "cwd": n.cwd.clone(), + "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": Value::Null, + "version": Value::Null, + "isSubagent": false, + "deleted": cc_deleted, + "imported": false, + "importedFrom": Value::Null, + "importedAt": Value::Null, + "model": n.model, + "totals": n.totals, + "messages": n.messages.len(), + "subagentCount": 0, + "firstTs": n.first_ts, + "lastTs": n.last_ts, + }, + "messages": n.messages, + "subagents": {}, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // hand-rolled wire encoding helpers (tests only) + fn enc_varint(mut v: u64, out: &mut Vec) { + loop { + let b = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + out.push(b); + break; + } + out.push(b | 0x80); + } + } + fn tag(field: u32, wt: u8, out: &mut Vec) { + enc_varint(((field as u64) << 3) | wt as u64, out); + } + fn put_varint(field: u32, v: u64, out: &mut Vec) { + tag(field, 0, out); + enc_varint(v, out); + } + fn put_bytes(field: u32, data: &[u8], out: &mut Vec) { + tag(field, 2, out); + enc_varint(data.len() as u64, out); + out.extend_from_slice(data); + } + fn put_str(field: u32, s: &str, out: &mut Vec) { + put_bytes(field, s.as_bytes(), out); + } + fn ts_msg(secs: u64) -> Vec { + let mut m = vec![]; + put_varint(1, secs, &mut m); + put_varint(2, 500_000_000, &mut m); + m + } + + fn user_step(text: &str) -> Vec { + let mut meta5 = vec![]; + put_bytes(1, &ts_msg(1_783_811_237), &mut meta5); + let mut u19 = vec![]; + put_str(2, text, &mut u19); + let mut att = vec![]; + put_str(1, "image/png", &mut att); + put_bytes(2, b"ABC", &mut att); + put_str(5, "/tmp/x.png", &mut att); + put_bytes(9, &att, &mut u19); + let mut step = vec![]; + put_varint(1, 14, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + put_bytes(19, &u19, &mut step); + step + } + + fn tool_step() -> Vec { + let mut call = vec![]; + put_str(1, "call-9", &mut call); + put_str(2, "run_command", &mut call); + put_str(3, "{\"CommandLine\":\"ls -la\",\"Cwd\":\"/tmp\",\"toolSummary\":\"Run\"}", &mut call); + let mut meta5 = vec![]; + put_bytes(1, &ts_msg(1_783_811_240), &mut meta5); + put_bytes(4, &call, &mut meta5); + let mut step = vec![]; + put_varint(1, 21, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + step + } + + fn gen_step(text: &str) -> Vec { + let mut stats = vec![]; + put_varint(1, 1132, &mut stats); + put_varint(2, 20245, &mut stats); + put_varint(3, 346, &mut stats); + let mut meta5 = vec![]; + put_bytes(1, &ts_msg(1_783_811_242), &mut meta5); + put_bytes(9, &stats, &mut meta5); + let mut t20 = vec![]; + put_str(1, text, &mut t20); + let mut step = vec![]; + put_varint(1, 15, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + put_bytes(20, &t20, &mut step); + step + } + + #[test] + fn decodes_steps() { + let mut n = Norm::default(); + push_step(&mut n, &user_step("修复登录")); + push_step(&mut n, &tool_step()); + push_step(&mut n, &gen_step("已修复。")); + assert_eq!(n.messages.len(), 3); + assert_eq!(n.messages[0]["role"], "user"); + assert_eq!(n.messages[0]["content"][0]["text"], "修复登录"); + assert_eq!(n.messages[0]["content"][1]["type"], "image"); + assert_eq!(n.messages[0]["content"][1]["source"]["data"], "QUJD"); + let tool = &n.messages[1]["content"][0]; + assert_eq!(tool["name"], "Bash"); + assert_eq!(tool["input"]["command"], "ls -la"); + assert_eq!(tool["id"], "call-9"); + assert_eq!(n.messages[2]["content"][0]["text"], "已修复。"); + assert_eq!(n.messages[2]["usage"]["inputTokens"], 20245); + assert_eq!(n.messages[2]["usage"]["outputTokens"], 346); + assert_eq!(n.totals["in"], 20245); + assert_eq!(n.totals["turns"], 1); + assert!(n.messages[0]["ts"].as_str().unwrap().starts_with("2026-")); + } + + #[test] + fn garbage_payload_is_skipped() { + let mut n = Norm::default(); + push_step(&mut n, &[0xff, 0x00, 0x13, 0x37]); + push_step(&mut n, b""); + assert!(n.messages.is_empty()); + } + + #[test] + fn b64_matches_reference() { + assert_eq!(b64_encode(b"ABC"), "QUJD"); + assert_eq!(b64_encode(b"AB"), "QUI="); + assert_eq!(b64_encode(b"A"), "QQ=="); + assert_eq!(b64_encode(b""), ""); + } + + #[test] + fn detects_paths() { + assert!(looks_agy_path(Path::new("/x/antigravity-cli/conversations/ab-1.db"))); + assert!(!looks_agy_path(Path::new("/x/antigravity-cli/conversation_summaries.db"))); + assert!(!looks_agy_path(Path::new("/x/conversations/notes.txt"))); + } +} diff --git a/src-tauri/src/codex.rs b/src-tauri/src/codex.rs index 525b5db..ace6985 100644 --- a/src-tauri/src/codex.rs +++ b/src-tauri/src/codex.rs @@ -21,7 +21,8 @@ #![allow(dead_code)] -use serde_json::{json, Map, Value}; +use crate::history::{image_block, Norm}; +use serde_json::{json, Value}; use std::fs; use std::path::{Path, PathBuf}; @@ -246,25 +247,6 @@ fn shape_output(out: &Value) -> (String, bool) { (s, false) } -/// data-URL input_image → Claude-style image source block, else None. -fn image_block(url: &str) -> Option { - let rest = url.strip_prefix("data:")?; - let (mime, b64) = rest.split_once(";base64,")?; - Some(json!({ "type": "image", "source": { "type": "base64", "media_type": mime, "data": b64 } })) -} - -pub struct Norm { - pub messages: Vec, - pub totals: Value, - pub model: Option, - pub first_ts: Option, - pub last_ts: Option, - pub cwd: Option, - pub session_id: Option, - pub git_branch: Option, - pub version: Option, -} - /// Normalize parsed rollout records into the renderer's message model. pub fn normalize(recs: &[Value]) -> Norm { let mut messages: Vec = vec![]; @@ -558,68 +540,7 @@ pub fn head_ids(recs: &[Value]) -> (Option, Option) { (None, None) } -// ---- sidecar customization (~/.ccbud/codex-meta.json): { "": {title?, tagList?, delete?} } ---- - -fn sidecar_path() -> PathBuf { - crate::store::ccbud_home().join("codex-meta.json") -} - -fn sidecar_cache() -> &'static std::sync::Mutex)>> { - static CACHE: std::sync::OnceLock)>>> = - std::sync::OnceLock::new(); - CACHE.get_or_init(|| std::sync::Mutex::new(None)) -} - -fn sidecar_mtime() -> f64 { - fs::metadata(sidecar_path()) - .and_then(|m| m.modified()) - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as f64) - .unwrap_or(0.0) -} - -fn read_sidecar() -> Map { - let mt = sidecar_mtime(); - if let Ok(guard) = sidecar_cache().lock() { - if let Some((cmt, map)) = guard.as_ref() { - if *cmt == mt { - return map.clone(); - } - } - } - let map = fs::read_to_string(sidecar_path()) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .and_then(|v| v.as_object().cloned()) - .unwrap_or_default(); - if let Ok(mut guard) = sidecar_cache().lock() { - *guard = Some((mt, map.clone())); - } - map -} - -fn write_sidecar(map: &Map) -> bool { - let dir = crate::store::ccbud_home(); - let _ = fs::create_dir_all(&dir); - let file = sidecar_path(); - let tmp = dir.join("codex-meta.json.tmp"); - let bytes = match serde_json::to_vec_pretty(&Value::Object(map.clone())) { - Ok(b) => b, - Err(_) => return false, - }; - if fs::write(&tmp, bytes).is_err() { - return false; - } - if fs::rename(&tmp, &file).is_err() { - let _ = fs::remove_file(&tmp); - return false; - } - if let Ok(mut guard) = sidecar_cache().lock() { - *guard = Some((sidecar_mtime(), map.clone())); - } - true -} +// ---- sidecar customization (shared store, ~/.ccbud/codex-meta.json, keyed by rollout stem) ---- fn stem_of(file: &Path) -> String { file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() @@ -627,29 +548,7 @@ fn stem_of(file: &Path) -> String { /// (custom title, tags, deleted) for a codex session, from the sidecar. fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { - let map = read_sidecar(); - let c = match map.get(&stem_of(file)) { - Some(v) => v, - None => return (None, vec![], false), - }; - let title = c - .get("title") - .and_then(|t| t.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()); - let tags = c - .get("tagList") - .and_then(|t| t.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|t| t.as_str()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - }) - .unwrap_or_default(); - let deleted = c.get("delete").and_then(|v| v.as_bool()).unwrap_or(false); - (title, tags, deleted) + crate::sidecar::meta(&crate::sidecar::codex_file(), &stem_of(file)) } pub fn is_deleted(file: &Path) -> bool { @@ -663,64 +562,12 @@ pub fn set_meta(file: &str, patch: &Value) -> Value { if stem.is_empty() { return json!({ "ok": false, "reason": "empty" }); } - let mut map = read_sidecar(); - let mut next = map - .get(&stem) - .and_then(|v| v.as_object()) - .cloned() - .unwrap_or_default(); - if let Some(t) = patch.get("title") { - let t = t.as_str().unwrap_or("").trim().to_string(); - if !t.is_empty() { - next.insert("title".into(), json!(t)); - } else { - next.remove("title"); - } - } - if let Some(tags) = patch.get("tags") { - let mut arr: Vec = vec![]; - if let Some(ta) = tags.as_array() { - for x in ta { - if let Some(s) = x.as_str() { - let s = s.trim(); - if !s.is_empty() && !arr.iter().any(|y| y == s) { - arr.push(s.to_string()); - } - } - } - } - if !arr.is_empty() { - next.insert("tagList".into(), json!(arr)); - } else { - next.remove("tagList"); - } - } - if let Some(d) = patch.get("delete") { - if d.as_bool().unwrap_or(false) { - next.insert("delete".into(), json!(true)); - } else { - next.remove("delete"); - } - } - if next.is_empty() { - map.remove(&stem); - } else { - map.insert(stem, Value::Object(next)); - } - if write_sidecar(&map) { - json!({ "ok": true }) - } else { - json!({ "ok": false, "reason": "write" }) - } + crate::sidecar::set_meta(&crate::sidecar::codex_file(), &stem, patch) } /// Drop a session's sidecar entry (after its rollout file is deleted forever). pub fn remove_meta(file: &str) { - let stem = stem_of(Path::new(file)); - let mut map = read_sidecar(); - if map.remove(&stem).is_some() { - let _ = write_sidecar(&map); - } + crate::sidecar::remove_meta(&crate::sidecar::codex_file(), &stem_of(Path::new(file))); } // ---- list/detail shapes (codex flavors of history.rs session_meta / get_session) ---- diff --git a/src-tauri/src/copilot.rs b/src-tauri/src/copilot.rs new file mode 100644 index 0000000..e45151e --- /dev/null +++ b/src-tauri/src/copilot.rs @@ -0,0 +1,437 @@ +// GitHub Copilot CLI session support — reads Copilot's on-disk session event logs and +// normalizes them into the SAME session/message shape the renderer consumes (history::Norm). +// +// Two layouts under `~/.copilot/session-state/`: +// new (≥1.0): /events.jsonl + sibling workspace.yaml (id/cwd/name/branch/timestamps, +// flat "key: value" lines — parsed without a YAML dependency) +// old: .jsonl flat files (same event schema; early builds carry no cwd at all, +// so those sessions group under the unknown-project bucket) +// +// An event line is `{type, data, id, timestamp, parentId}`. Conversation content: +// session.start → cwd/session id/version (data.context.cwd on newer builds) +// session.model_change → model (data.newModel) +// user.message → user text (data.content) +// assistant.message → assistant text + tool_use blocks (data.content, +// data.toolRequests[{toolCallId,name,arguments}], data.model) +// tool.execution_complete → tool_result (data.toolCallId, data.success, data.result.content) +// Everything else (session.info, system.*, turn markers, tool.execution_start) is harness +// plumbing and skipped — tool arguments already ride the assistant.message request. +// +// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) +// keyed `copilot:` — the files belong to another tool and are never rewritten. + +#![allow(dead_code)] + +use crate::history::Norm; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Copilot's config dir as a history-dir entry string (`~/.copilot`). +pub fn default_root() -> PathBuf { + home().join(".copilot") +} + +pub fn copilot_label() -> String { + crate::store::collapse_home(&default_root().to_string_lossy()) +} + +pub fn root_exists() -> bool { + default_root().join("session-state").is_dir() +} + +/// Walk every session log under a `session-state/` tree: flat `.jsonl` (old) and +/// `/events.jsonl` (new). Dirs without an events.jsonl (created-but-unused sessions, +/// checkpoint-only remnants) hold no conversation and are skipped. +pub fn walk(state_dir: &Path, cb: &mut F) { + let entries = match fs::read_dir(state_dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + cb(p); + } else if p.is_dir() { + let events = p.join("events.jsonl"); + if events.is_file() { + cb(events); + } + } + } +} + +/// Container-shape test for detail/edit routing: a .jsonl directly in `session-state/`, or an +/// `events.jsonl` whose grandparent is `session-state/`. +pub fn looks_copilot_path(file: &Path) -> bool { + let parent_named = |p: &Path, name: &str| { + p.file_name().and_then(|n| n.to_str()).map(|n| n == name).unwrap_or(false) + }; + match file.file_name().and_then(|n| n.to_str()) { + Some("events.jsonl") => file + .parent() + .and_then(|d| d.parent()) + .map(|gp| parent_named(gp, "session-state")) + .unwrap_or(false), + Some(n) if n.ends_with(".jsonl") => { + file.parent().map(|d| parent_named(d, "session-state")).unwrap_or(false) + } + _ => false, + } +} + +/// The session uuid — the flat file's stem, or the events.jsonl dir name. +fn session_uuid(file: &Path) -> String { + if file.file_name().and_then(|n| n.to_str()) == Some("events.jsonl") { + file.parent() + .and_then(|d| d.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + } else { + file.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string() + } +} + +fn sidecar_key(file: &Path) -> String { + format!("copilot:{}", session_uuid(file)) +} + +fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "copilot:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} + +/// Sibling workspace.yaml of an events.jsonl, parsed as flat `key: value` lines (the file is +/// machine-written and flat; no YAML dependency needed). None for old flat sessions. +fn workspace_yaml(file: &Path) -> Option> { + if file.file_name().and_then(|n| n.to_str()) != Some("events.jsonl") { + return None; + } + let text = fs::read_to_string(file.parent()?.join("workspace.yaml")).ok()?; + let mut map = serde_json::Map::new(); + for line in text.lines() { + if let Some((k, v)) = line.split_once(':') { + let (k, v) = (k.trim(), v.trim()); + if !k.is_empty() && !k.starts_with('#') && !v.is_empty() { + map.insert(k.to_string(), json!(v.trim_matches('"').trim_matches('\''))); + } + } + } + Some(map) +} + +/// Copilot tool name + arguments (already an object) → (renderer tool name, renderer input). +fn map_tool(name: &str, args: &Value) -> (String, Value) { + let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let keep = |v: &Value| if v.is_object() { v.clone() } else { json!({}) }; + match name { + "bash" => { + let mut input = json!({ "command": s("command") }); + if !s("description").is_empty() { + input["description"] = json!(s("description")); + } + ("Bash".into(), input) + } + "view" => ("Read".into(), json!({ "file_path": s("path") })), + "edit" | "str_replace" => ( + "Edit".into(), + json!({ "file_path": s("path"), "old_string": s("old_str"), "new_string": s("new_str") }), + ), + "create" => ("Write".into(), json!({ "file_path": s("path"), "content": s("file_text") })), + "rg" => { + let mut input = json!({ "pattern": s("pattern") }); + if let Some(p) = args.get("paths") { + input["path"] = if p.is_array() { + json!(p.as_array().unwrap().iter().filter_map(|x| x.as_str()).collect::>().join(" ")) + } else { + p.clone() + }; + } + if !s("glob").is_empty() { + input["glob"] = json!(s("glob")); + } + ("Grep".into(), input) + } + "glob" => ("Glob".into(), json!({ "pattern": s("pattern"), "path": s("paths") })), + "apply_patch" => ("ApplyPatch".into(), json!({ "patch": s("str") })), + _ => (name.to_string(), keep(args)), + } +} + +/// Normalize parsed event records into the renderer's message model. +pub fn normalize(recs: &[Value]) -> Norm { + let mut n = Norm::default(); + for rec in recs { + let ty = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); + let data = rec.get("data").cloned().unwrap_or(Value::Null); + let ts = rec.get("timestamp").and_then(|v| v.as_str()); + let with_ts = |mut m: Value| { + if let Some(t) = ts { + m["ts"] = json!(t); + } + m + }; + match ty { + "session.start" => { + if n.session_id.is_none() { + n.session_id = data.get("sessionId").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + if n.version.is_none() { + n.version = data.get("copilotVersion").and_then(|v| v.as_str()).map(|s| s.to_string()); + } + if n.cwd.is_none() { + n.cwd = data + .get("context") + .and_then(|c| c.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + if n.git_branch.is_none() { + n.git_branch = data + .get("context") + .and_then(|c| c.get("branch")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + "session.model_change" => { + if let Some(m) = data.get("newModel").and_then(|v| v.as_str()) { + n.model = Some(m.to_string()); + } + } + "user.message" => { + let text = data.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if !text.trim().is_empty() { + n.messages + .push(with_ts(json!({ "role": "user", "content": [{ "type": "text", "text": text }] }))); + } + } + "assistant.message" => { + if let Some(m) = data.get("model").and_then(|v| v.as_str()) { + n.model = Some(m.to_string()); + } + let mut blocks: Vec = vec![]; + let text = data.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if !text.trim().is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + if let Some(calls) = data.get("toolRequests").and_then(|c| c.as_array()) { + for call in calls { + let name = call.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let args = call.get("arguments").cloned().unwrap_or(json!({})); + let (tname, input) = map_tool(name, &args); + let id = call.get("toolCallId").and_then(|v| v.as_str()).unwrap_or(""); + blocks.push(json!({ "type": "tool_use", "id": id, "name": tname, "input": input })); + } + } + if !blocks.is_empty() { + let mut m = json!({ "role": "assistant", "content": blocks }); + if let Some(md) = &n.model { + m["modelActual"] = json!(md); + } + n.messages.push(with_ts(m)); + } + } + "tool.execution_complete" => { + let id = data.get("toolCallId").and_then(|v| v.as_str()).unwrap_or(""); + let text = data + .get("result") + .and_then(|r| r.get("content")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let mut tr = json!({ "type": "tool_result", "tool_use_id": id, "content": text }); + if data.get("success").and_then(|v| v.as_bool()) == Some(false) { + tr["is_error"] = json!(true); + } + n.messages.push(with_ts(json!({ "role": "user", "content": [tr] }))); + } + _ => {} // session.info / system.* / turn markers / execution_start: harness plumbing + } + } + n.first_ts = n.messages.first().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n.last_ts = n.messages.last().and_then(|m| m.get("ts")).and_then(|v| v.as_str()).map(|s| s.to_string()); + n +} + +/// List-row meta: workspace.yaml when present (new layout — has copilot's own session name), +/// else the event head (old flat layout). +pub fn session_meta_from(file: &Path, recs: &[Value], dir_id: &str, dir_label: &str) -> Option { + let meta = fs::metadata(file).ok()?; + let ws = workspace_yaml(file); + let n = normalize(recs); + let uuid = session_uuid(file); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); + let ws_str = |k: &str| { + ws.as_ref() + .and_then(|m| m.get(k)) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + }; + let auto_title = ws_str("name").unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let cwd = ws_str("cwd").or_else(|| n.cwd.clone()); + let created = ws_str("created_at") + .or_else(|| n.first_ts.clone()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) + .map(|d| d.timestamp_millis() as f64) + .unwrap_or_else(|| crate::history::created_ms(file)); + let mt = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0); + Some(json!({ + "id": format!("copilot:{}", uuid), + "file": file.to_string_lossy(), + "source": "copilot", + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": ws_str("branch").or_else(|| n.git_branch.clone()).map(Value::from).unwrap_or(Value::Null), + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": n.model, + "isSubagent": false, + "imported": false, + "deleted": cc_deleted, + "createdAt": created, + "lastActivity": mt, + "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, + })) +} + +/// Full-detail shape (history.rs get_session routes here). +pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { + let path = Path::new(file); + let n = normalize(recs); + let ws = workspace_yaml(path); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); + let ws_name = ws + .as_ref() + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()); + let auto_title = ws_name.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let uuid = session_uuid(path); + let cwd = ws + .as_ref() + .and_then(|m| m.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| n.cwd.clone()); + json!({ + "meta": { + "id": format!("copilot:{}", uuid), + "file": file, + "source": "copilot", + "assistant": "Copilot", + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": Value::Null, + "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": n.git_branch.clone(), + "version": n.version.clone(), + "isSubagent": false, + "deleted": cc_deleted, + "imported": false, + "importedFrom": Value::Null, + "importedAt": Value::Null, + "model": n.model, + "totals": n.totals, + "messages": n.messages.len(), + "subagentCount": 0, + "firstTs": n.first_ts, + "lastTs": n.last_ts, + }, + "messages": n.messages, + "subagents": {}, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ev(ts: &str, ty: &str, data: Value) -> Value { + json!({ "type": ty, "data": data, "id": "e", "timestamp": ts, "parentId": null }) + } + + fn recs() -> Vec { + vec![ + ev("2026-07-12T07:26:54.363Z", "session.start", json!({ + "sessionId": "d34d-1111", "copilotVersion": "1.0.70", + "context": { "cwd": "/tmp/shhh", "gitRoot": "/tmp/shhh", "branch": "main" } + })), + ev("2026-07-12T07:27:00.685Z", "session.model_change", json!({ "newModel": "gpt-5.6" })), + ev("2026-07-12T07:27:05.000Z", "system.message", json!({ "role": "system", "content": "You are Copilot" })), + ev("2026-07-12T07:27:14.000Z", "user.message", json!({ "content": "修沙盒问题", "attachments": [] })), + ev("2026-07-12T07:27:15.000Z", "assistant.message", json!({ + "messageId": "m1", "model": "gpt-5.6", "content": "我先搜一下。", + "toolRequests": [ + { "toolCallId": "call_A", "name": "rg", "arguments": { "pattern": "sandbox", "paths": ".", "glob": "*.plist" } }, + { "toolCallId": "call_B", "name": "bash", "arguments": { "command": "ls", "description": "List", "mode": "sync", "sessionId": "main" } } + ] + })), + ev("2026-07-12T07:27:16.000Z", "tool.execution_start", json!({ "toolCallId": "call_A", "toolName": "rg" })), + ev("2026-07-12T07:27:17.000Z", "tool.execution_complete", json!({ + "toolCallId": "call_A", "success": true, "result": { "content": "a.plist: sandbox" } + })), + ev("2026-07-12T07:27:18.000Z", "tool.execution_complete", json!({ + "toolCallId": "call_B", "success": false, "result": { "content": "boom" } + })), + ] + } + + #[test] + fn normalizes_events() { + let n = normalize(&recs()); + assert_eq!(n.messages.len(), 4); // user, assistant(+2 tools), 2 results + assert_eq!(n.messages[0]["content"][0]["text"], "修沙盒问题"); + let a = &n.messages[1]; + assert_eq!(a["content"][0]["text"], "我先搜一下。"); + assert_eq!(a["content"][1]["name"], "Grep"); + assert_eq!(a["content"][1]["input"]["pattern"], "sandbox"); + assert_eq!(a["content"][2]["name"], "Bash"); + assert_eq!(n.messages[2]["content"][0]["tool_use_id"], "call_A"); + assert_eq!(n.messages[3]["content"][0]["is_error"], true); + assert_eq!(n.model.as_deref(), Some("gpt-5.6")); + assert_eq!(n.cwd.as_deref(), Some("/tmp/shhh")); + assert_eq!(n.session_id.as_deref(), Some("d34d-1111")); + assert_eq!(n.first_ts.as_deref(), Some("2026-07-12T07:27:14.000Z")); + } + + #[test] + fn detects_paths_and_uuids() { + let new = Path::new("/x/.copilot/session-state/abcd-1/events.jsonl"); + let old = Path::new("/x/.copilot/session-state/abcd-2.jsonl"); + assert!(looks_copilot_path(new)); + assert!(looks_copilot_path(old)); + assert!(!looks_copilot_path(Path::new("/x/projects/-tmp/abcd.jsonl"))); + assert_eq!(session_uuid(new), "abcd-1"); + assert_eq!(session_uuid(old), "abcd-2"); + } +} diff --git a/src-tauri/src/exporthtml.rs b/src-tauri/src/exporthtml.rs index edf13df..5633b38 100644 --- a/src-tauri/src/exporthtml.rs +++ b/src-tauri/src/exporthtml.rs @@ -285,11 +285,10 @@ fn read_subagents(file: &Path) -> Value { Value::Object(by_tool) } -// Codex rollout → the same export data shape (messages re-capped + field names the viewer -// runtime reads: model / usage{in,out,cacheRead} / stop), with meta.assistant = "Codex" so the -// exported page labels turns correctly. -fn build_codex_data(file: &str, recs: &[Value]) -> Value { - let sess = crate::codex::session_from_recs(file, recs); +// Non-Claude session detail → the export data shape (messages re-capped + field names the +// viewer runtime reads: model / usage{in,out,cacheRead} / stop). `assistant` labels turns on +// the exported page (Codex / Grok / Copilot / Antigravity). +fn build_from_session(sess: Value, assistant: &str) -> Value { let m = sess.get("meta").cloned().unwrap_or_else(|| json!({})); let messages: Vec = sess .get("messages") @@ -328,7 +327,7 @@ fn build_codex_data(file: &str, recs: &[Value]) -> Value { json!({ "meta": { "title": if title.is_empty() { "(conversation)".to_string() } else { title }, - "assistant": "Codex", + "assistant": assistant, "model": m.get("model").cloned().unwrap_or(Value::Null), "project": m.get("project").cloned().unwrap_or(Value::Null), "cwd": m.get("cwd").cloned().unwrap_or(Value::Null), @@ -351,9 +350,24 @@ fn build_codex_data(file: &str, recs: &[Value]) -> Value { pub fn build_data(file: &str) -> Value { let path = Path::new(file); + // Foreign sources first — container-shape routing (one of them is SQLite, not jsonl). + match crate::history::foreign_kind(path) { + Some(crate::history::Foreign::Grok) => { + let recs = parse_jsonl(path); + return build_from_session(crate::grok::session_from_recs(file, &recs), "Grok"); + } + Some(crate::history::Foreign::Copilot) => { + let recs = parse_jsonl(path); + return build_from_session(crate::copilot::session_from_recs(file, &recs), "Copilot"); + } + Some(crate::history::Foreign::Antigravity) => { + return build_from_session(crate::antigravity::session_from(file), "Antigravity"); + } + None => {} + } let recs = parse_jsonl(path); if crate::codex::looks_codex(&recs) { - return build_codex_data(file, &recs); + return build_from_session(crate::codex::session_from_recs(file, &recs), "Codex"); } let meta_rec = recs.iter().find(|r| r.get("cwd").is_some()).or_else(|| recs.iter().find(|r| r.get("sessionId").is_some())); let s = shape_session(&recs); diff --git a/src-tauri/src/grok.rs b/src-tauri/src/grok.rs new file mode 100644 index 0000000..6217dd6 --- /dev/null +++ b/src-tauri/src/grok.rs @@ -0,0 +1,537 @@ +// Grok Build CLI session support — reads xAI Grok's on-disk sessions +// (`~/.grok/sessions///chat_history.jsonl`, sibling `summary.json` +// carrying id/cwd/title/model/git/timestamps) and normalizes them into the SAME session/message +// shape the renderer consumes (see history::Norm), so the 对话 view browses Grok sessions +// without renderer forks. +// +// A chat_history line is one of: `system` (harness prompt — skipped), `user` (content blocks of +// text / data-URL image; the human prose is wrapped in tags, harness wrappers like +// / are dropped), `reasoning` ({summary:[{summary_text}]} → thinking), +// `assistant` ({content, tool_calls:[{id,name,arguments-json}]}), and `tool_result` +// ({tool_call_id, content, images?}). Tool names are mapped onto the renderer's native +// vocabulary (both grok tool-name generations: read_file/Read → Read, Shell → Bash, …). +// +// The same uuid dir also holds events/updates/rewind_points/hunk_records .jsonl — only +// chat_history.jsonl is the conversation; walkers must never sweep the rest. +// +// Title/tags/soft-delete live in the shared foreign-CLI sidecar (~/.ccbud/agent-meta.json) +// keyed `grok:` — chat_history stems aren't unique, and the files belong to another tool. + +#![allow(dead_code)] + +use crate::history::{image_block, Norm}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +fn home() -> PathBuf { + std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) +} + +/// Grok's DEFAULT config dir as a history-dir entry string (`~/.grok`). Honors GROK_HOME the way +/// the grok CLI does (summary.json echoes it as `grok_home`). Only the auto-add migration keys +/// off this — browsing walks every configured dir's `sessions/` tree. +pub fn default_root() -> PathBuf { + match std::env::var("GROK_HOME") { + Ok(h) if !h.trim().is_empty() => PathBuf::from(h), + _ => home().join(".grok"), + } +} + +pub fn grok_label() -> String { + crate::store::collapse_home(&default_root().to_string_lossy()) +} + +/// A grok install exists when its sessions tree holds at least one percent-encoded cwd dir. +pub fn root_exists() -> bool { + let sessions = default_root().join("sessions"); + fs::read_dir(&sessions) + .map(|entries| { + entries + .flatten() + .any(|e| is_cwd_dir_name(&e.file_name().to_string_lossy()) && e.path().is_dir()) + }) + .unwrap_or(false) +} + +/// Grok encodes each workspace cwd as a percent-encoded absolute path dir ("%2FUsers%2F…") — +/// the marker that distinguishes a grok sessions/ child from Codex's YYYY date shards. +pub fn is_cwd_dir_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.starts_with("%2f") || lower.starts_with("%3a%5c") // unix "/", windows "X:\" oddity-proof +} + +/// Session files under one encoded-cwd dir: `//chat_history.jsonl`. +pub fn walk_cwd_dir(dir: &Path, cb: &mut F) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for ent in entries.flatten() { + let p = ent.path(); + if !p.is_dir() { + continue; + } + let chat = p.join("chat_history.jsonl"); + if chat.is_file() { + cb(chat); + } + } +} + +/// Container-shape test for detail/edit routing: `…/sessions///chat_history.jsonl`. +pub fn looks_grok_path(file: &Path) -> bool { + if file.file_name().and_then(|n| n.to_str()) != Some("chat_history.jsonl") { + return false; + } + file.parent() + .and_then(|uuid_dir| uuid_dir.parent()) + .and_then(|enc| enc.file_name()) + .map(|n| is_cwd_dir_name(&n.to_string_lossy())) + .unwrap_or(false) +} + +/// The session uuid (its dir name) — sidecar key and renderer id both build on it. +fn session_uuid(file: &Path) -> String { + file.parent() + .and_then(|d| d.file_name()) + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +fn sidecar_key(file: &Path) -> String { + format!("grok:{}", session_uuid(file)) +} + +fn sidecar_meta(file: &Path) -> (Option, Vec, bool) { + crate::sidecar::meta(&crate::sidecar::agent_file(), &sidecar_key(file)) +} + +pub fn is_deleted(file: &Path) -> bool { + sidecar_meta(file).2 +} + +pub fn set_meta(file: &str, patch: &Value) -> Value { + let key = sidecar_key(Path::new(file)); + if key == "grok:" { + return json!({ "ok": false, "reason": "empty" }); + } + crate::sidecar::set_meta(&crate::sidecar::agent_file(), &key, patch) +} + +/// Sibling summary.json of a chat_history.jsonl (grok's own session metadata). +fn summary_of(file: &Path) -> Option { + let p = file.parent()?.join("summary.json"); + serde_json::from_str(&fs::read_to_string(p).ok()?).ok() +} + +/// Minimal percent-decoding for grok's encoded-cwd dir names (fallback when summary.json +/// is missing; the record cwd wins when present). Also used on Antigravity's file:// uris. +pub(crate) fn percent_decode(s: &str) -> String { + let bytes = s.as_bytes(); + let mut out: Vec = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + out.push(b); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn rfc3339_ms(s: &str) -> Option { + chrono::DateTime::parse_from_rfc3339(s) + .ok() + .map(|d| d.timestamp_millis() as f64) +} + +/// Harness-injected user text (environment wrappers) — hidden from the timeline. +fn is_meta_user_text(t: &str) -> bool { + let t = t.trim_start(); + ["", "", "", "…` (the human prose envelope grok writes). +fn unwrap_user_query(t: &str) -> String { + match t.split_once("") { + Some((_, rest)) => rest.split("").next().unwrap_or(rest).trim().to_string(), + None => t.trim().to_string(), + } +} + +/// Grok tool name + parsed arguments → (renderer tool name, renderer input). Covers both grok +/// tool-name generations (snake_case and CamelCase). +fn map_tool(name: &str, args: &Value) -> (String, Value) { + let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let keep = |v: &Value| if v.is_object() { v.clone() } else { json!({}) }; + match name { + "run_terminal_command" | "Shell" => { + let mut input = json!({ "command": s("command") }); + if !s("description").is_empty() { + input["description"] = json!(s("description")); + } + ("Bash".into(), input) + } + "read_file" | "Read" => { + let path = if !s("target_file").is_empty() { s("target_file") } else { s("path") }; + let mut input = json!({ "file_path": path }); + for k in ["offset", "limit"] { + if let Some(v) = args.get(k) { + if !v.is_null() { + input[k] = v.clone(); + } + } + } + ("Read".into(), input) + } + "grep" | "Grep" | "grep_search" => { + let mut input = json!({ "pattern": if !s("pattern").is_empty() { s("pattern") } else { s("query") } }); + if !s("path").is_empty() { + input["path"] = json!(s("path")); + } + ("Grep".into(), input) + } + "search_replace" => ("Edit".into(), keep(args)), + "StrReplace" => ( + "Edit".into(), + json!({ "file_path": s("path"), "old_string": s("old_string"), "new_string": s("new_string") }), + ), + "write" => ("Write".into(), keep(args)), + "Write" => ("Write".into(), json!({ "file_path": s("path"), "content": s("contents") })), + "list_dir" => ("LS".into(), json!({ "path": s("target_directory") })), + "Glob" => ("Glob".into(), json!({ "pattern": s("glob_pattern"), "path": s("target_directory") })), + "todo_write" | "TodoWrite" => ("TodoWrite".into(), keep(args)), + "web_fetch" | "WebFetch" => ("WebFetch".into(), json!({ "url": s("url") })), + "WebSearch" => ("WebSearch".into(), json!({ "query": s("search_term") })), + _ => (name.to_string(), keep(args)), + } +} + +/// Normalize parsed chat_history records (+ the sibling summary) into the renderer's message +/// model. Lines carry no timestamps — session-level times come from summary.json. +pub fn normalize(recs: &[Value], summary: Option<&Value>) -> Norm { + let mut n = Norm::default(); + let sum = summary.cloned().unwrap_or(Value::Null); + n.model = sum.get("current_model_id").and_then(|v| v.as_str()).map(|s| s.to_string()); + n.cwd = sum + .get("info") + .and_then(|i| i.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + n.session_id = sum + .get("info") + .and_then(|i| i.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + n.git_branch = sum.get("head_branch").and_then(|v| v.as_str()).map(|s| s.to_string()); + n.first_ts = sum.get("created_at").and_then(|v| v.as_str()).map(|s| s.to_string()); + n.last_ts = sum + .get("last_active_at") + .or_else(|| sum.get("updated_at")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + for rec in recs { + let ty = rec.get("type").and_then(|v| v.as_str()).unwrap_or(""); + match ty { + "user" => { + let mut blocks: Vec = vec![]; + if let Some(arr) = rec.get("content").and_then(|c| c.as_array()) { + for b in arr { + match b.get("type").and_then(|t| t.as_str()).unwrap_or("") { + "text" => { + let raw = b.get("text").and_then(|t| t.as_str()).unwrap_or(""); + if is_meta_user_text(raw) && !raw.contains("") { + continue; + } + let text = unwrap_user_query(raw); + if !text.is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + } + "image" => { + if let Some(img) = + b.get("url").and_then(|u| u.as_str()).and_then(image_block) + { + blocks.push(img); + } + } + _ => {} + } + } + } else if let Some(t) = rec.get("content").and_then(|c| c.as_str()) { + let text = unwrap_user_query(t); + if !text.is_empty() && !is_meta_user_text(t) { + blocks.push(json!({ "type": "text", "text": text })); + } + } + if !blocks.is_empty() { + n.messages.push(json!({ "role": "user", "content": blocks })); + } + } + "reasoning" => { + let txt = rec + .get("summary") + .and_then(|s| s.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + if !txt.trim().is_empty() { + let mut m = json!({ "role": "assistant", "content": [{ "type": "thinking", "thinking": txt }] }); + if let Some(md) = &n.model { + m["modelActual"] = json!(md); + } + n.messages.push(m); + } + } + "assistant" => { + let mut blocks: Vec = vec![]; + let text = rec.get("content").and_then(|c| c.as_str()).unwrap_or(""); + if !text.trim().is_empty() { + blocks.push(json!({ "type": "text", "text": text })); + } + if let Some(calls) = rec.get("tool_calls").and_then(|c| c.as_array()) { + for call in calls { + let name = call.get("name").and_then(|v| v.as_str()).unwrap_or("tool"); + let args: Value = call + .get("arguments") + .and_then(|v| v.as_str()) + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_else(|| call.get("arguments").cloned().unwrap_or(json!({}))); + let (tname, input) = map_tool(name, &args); + let id = call.get("id").and_then(|v| v.as_str()).unwrap_or(""); + blocks.push(json!({ "type": "tool_use", "id": id, "name": tname, "input": input })); + } + } + if !blocks.is_empty() { + let mut m = json!({ "role": "assistant", "content": blocks }); + if let Some(md) = &n.model { + m["modelActual"] = json!(md); + } + n.messages.push(m); + } + } + "tool_result" => { + let id = rec.get("tool_call_id").and_then(|v| v.as_str()).unwrap_or(""); + let text = rec.get("content").and_then(|c| c.as_str()).unwrap_or("").to_string(); + let images: Vec = rec + .get("images") + .and_then(|a| a.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|b| b.get("url").and_then(|u| u.as_str()).and_then(image_block)) + .collect() + }) + .unwrap_or_default(); + let content: Value = if images.is_empty() { + json!(text) + } else { + let mut blocks = vec![json!({ "type": "text", "text": text })]; + blocks.extend(images); + json!(blocks) + }; + n.messages + .push(json!({ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": id, "content": content }] })); + } + _ => {} // system / unknown: harness plumbing, not conversation + } + } + n +} + +/// List-row meta: summary.json carries everything cheap (title/cwd/model/times); the file head +/// is only parsed when grok didn't store a title yet (fallback to first user prose). +pub fn session_meta_from(file: &Path, dir_id: &str, dir_label: &str) -> Option { + let meta = fs::metadata(file).ok()?; + let sum = summary_of(file); + let uuid = session_uuid(file); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(file); + let sum_title = sum + .as_ref() + .and_then(|s| s.get("generated_title").or_else(|| s.get("session_summary"))) + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let auto_title = sum_title.unwrap_or_else(|| { + let recs = crate::history::parse_lines(&crate::history::read_head(file, 131072)); + let n = normalize(&recs, sum.as_ref()); + crate::history::first_user_text(&n.messages) + }); + let cwd = sum + .as_ref() + .and_then(|s| s.get("info")) + .and_then(|i| i.get("cwd")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + file.parent() + .and_then(|d| d.parent()) + .and_then(|enc| enc.file_name()) + .map(|nm| percent_decode(&nm.to_string_lossy())) + }); + let created = sum + .as_ref() + .and_then(|s| s.get("created_at")) + .and_then(|v| v.as_str()) + .and_then(rfc3339_ms) + .unwrap_or_else(|| crate::history::created_ms(file)); + let mt = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0); + Some(json!({ + "id": format!("grok:{}", uuid), + "file": file.to_string_lossy(), + "source": "grok", + "dirId": dir_id, + "dirLabel": dir_label, + "sessionId": sum + .as_ref() + .and_then(|s| s.get("info")) + .and_then(|i| i.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or(&uuid), + "cwd": cwd.clone(), + "project": cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": sum.as_ref().and_then(|s| s.get("head_branch")).cloned().unwrap_or(Value::Null), + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "model": sum.as_ref().and_then(|s| s.get("current_model_id")).cloned().unwrap_or(Value::Null), + "isSubagent": false, + "imported": false, + "deleted": cc_deleted, + "createdAt": created, + "lastActivity": mt, + "sizeKB": (meta.len() as f64 / 1024.0).round() as i64, + })) +} + +/// Full-detail shape (history.rs get_session routes here). +pub fn session_from_recs(file: &str, recs: &[Value]) -> Value { + let path = Path::new(file); + let sum = summary_of(path); + let n = normalize(recs, sum.as_ref()); + let (cc_title, cc_tags, cc_deleted) = sidecar_meta(path); + let sum_title = sum + .as_ref() + .and_then(|s| s.get("generated_title").or_else(|| s.get("session_summary"))) + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let auto_title = sum_title.unwrap_or_else(|| crate::history::first_user_text(&n.messages)); + let uuid = session_uuid(path); + json!({ + "meta": { + "id": format!("grok:{}", uuid), + "file": file, + "source": "grok", + "assistant": "Grok", + "title": cc_title.clone().unwrap_or_else(|| auto_title.clone()), + "autoTitle": auto_title, + "tags": cc_tags, + "summary": Value::Null, + "sessionId": n.session_id.clone().unwrap_or_else(|| uuid.clone()), + "cwd": n.cwd.clone(), + "project": n.cwd.as_deref().map(crate::history::base_name).unwrap_or_default(), + "gitBranch": n.git_branch.clone(), + "version": Value::Null, + "isSubagent": false, + "deleted": cc_deleted, + "imported": false, + "importedFrom": Value::Null, + "importedAt": Value::Null, + "model": n.model, + "totals": n.totals, + "messages": n.messages.len(), + "subagentCount": 0, + "firstTs": n.first_ts, + "lastTs": n.last_ts, + }, + "messages": n.messages, + "subagents": {}, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn recs() -> Vec { + vec![ + json!({ "type": "system", "content": "You are Grok…" }), + json!({ "type": "user", "content": [{ "type": "text", "text": "\nOS: macos\n\n\n\nclean\n\n" }] }), + json!({ "type": "user", "content": [ + { "type": "text", "text": "\n修复登录 bug\n" }, + { "type": "image", "url": "data:image/png;base64,QUJD" } + ] }), + json!({ "type": "reasoning", "id": "rs_1", "summary": [{ "type": "summary_text", "text": "Scanning the repo" }] }), + json!({ "type": "assistant", "content": "先看下目录。", "tool_calls": [ + { "id": "call-1", "name": "run_terminal_command", "arguments": "{\"command\":\"ls\",\"description\":\"List files\"}" }, + { "id": "call-2", "name": "read_file", "arguments": "{\"target_file\":\"src/app.js\"}" } + ] }), + json!({ "type": "tool_result", "tool_call_id": "call-1", "content": "a.txt\nb.txt" }), + json!({ "type": "tool_result", "tool_call_id": "call-2", "content": "console.log(1)", "images": [{ "type": "image", "url": "data:image/png;base64,REVG" }] }), + ] + } + + fn summary() -> Value { + json!({ + "info": { "id": "0199-aaaa", "cwd": "/tmp/proj" }, + "generated_title": "Fix login bug", + "created_at": "2026-06-18T06:27:07.777809Z", + "last_active_at": "2026-06-18T06:57:37.242478Z", + "current_model_id": "grok-build", + "head_branch": "main", + }) + } + + #[test] + fn normalizes_conversation() { + let s = summary(); + let n = normalize(&recs(), Some(&s)); + // harness wrapper user turn dropped; real turns: user, thinking, assistant+tools, 2 results + assert_eq!(n.messages.len(), 5); + assert_eq!(n.messages[0]["role"], "user"); + assert_eq!(n.messages[0]["content"][0]["text"], "修复登录 bug"); + assert_eq!(n.messages[0]["content"][1]["type"], "image"); + assert_eq!(n.messages[1]["content"][0]["type"], "thinking"); + let a = &n.messages[2]; + assert_eq!(a["content"][0]["text"], "先看下目录。"); + assert_eq!(a["content"][1]["name"], "Bash"); + assert_eq!(a["content"][1]["input"]["command"], "ls"); + assert_eq!(a["content"][2]["name"], "Read"); + assert_eq!(a["content"][2]["input"]["file_path"], "src/app.js"); + assert_eq!(n.messages[3]["content"][0]["tool_use_id"], "call-1"); + // image-carrying result becomes a block array + assert_eq!(n.messages[4]["content"][0]["content"][1]["type"], "image"); + assert_eq!(n.model.as_deref(), Some("grok-build")); + assert_eq!(n.cwd.as_deref(), Some("/tmp/proj")); + } + + #[test] + fn detects_cwd_dirs_and_paths() { + assert!(is_cwd_dir_name("%2FUsers%2Fme%2Fcode")); + assert!(is_cwd_dir_name("%2fusers%2fme")); + assert!(!is_cwd_dir_name("2026")); + assert_eq!(percent_decode("%2FUsers%2Fme"), "/Users/me"); + let p = Path::new("/x/sessions/%2FUsers%2Fme/0199-aaaa/chat_history.jsonl"); + assert!(looks_grok_path(p)); + assert!(!looks_grok_path(Path::new("/x/sessions/2026/01/01/rollout-1.jsonl"))); + assert_eq!(session_uuid(p), "0199-aaaa"); + } +} diff --git a/src-tauri/src/history.rs b/src-tauri/src/history.rs index 2d76c02..1ce2310 100644 --- a/src-tauri/src/history.rs +++ b/src-tauri/src/history.rs @@ -68,7 +68,7 @@ pub(crate) fn parse_lines(text: &str) -> Vec { out } -fn read_head(file: &Path, max: usize) -> String { +pub(crate) fn read_head(file: &Path, max: usize) -> String { use std::io::Read; let mut f = match fs::File::open(file) { Ok(f) => f, @@ -175,13 +175,38 @@ pub(crate) fn read_ccbud(recs: &[Value]) -> (Option, Vec, bool) (title, tags, deleted) } +/// The foreign-CLI session sources routed by CONTAINER SHAPE (their path layouts are +/// distinctive per tool, and one of them isn't even jsonl) — content sniffing stays reserved +/// for the historical Claude-vs-Codex jsonl split. +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Foreign { + Grok, + Copilot, + Antigravity, +} + +pub(crate) fn foreign_kind(file: &Path) -> Option { + if crate::grok::looks_grok_path(file) { + return Some(Foreign::Grok); + } + if crate::copilot::looks_copilot_path(file) { + return Some(Foreign::Copilot); + } + if crate::antigravity::looks_agy_path(file) { + return Some(Foreign::Antigravity); + } + None +} + /// Cached soft-delete verdict for one file: a Claude session's flag (rides its first line, so it's -/// final for a given mtime), or "this is a Codex rollout" (whose flag lives in the sidecar and can -/// flip WITHOUT touching the file — so only the format verdict is cached, never the flag). +/// final for a given mtime), or "this belongs to another CLI" (Codex rollout / foreign source, +/// whose flag lives in a sidecar and can flip WITHOUT touching the file — so only the format +/// verdict is cached, never the flag). #[derive(Clone, Copy)] enum DelKind { Claude(bool), Codex, + Foreign(Foreign), } /// Process-lifetime memo of soft-delete status, keyed `path -> (mtime, kind)`. mtime is the @@ -204,16 +229,21 @@ fn is_session_deleted(file: &Path) -> bool { .ok() .and_then(|c| c.get(file).filter(|(cmt, _)| *cmt == mt).map(|(_, k)| *k)); let kind = cached.unwrap_or_else(|| { - // Read the same window session_meta uses: a Codex rollout's first (session_meta) line - // embeds the full system prompt (~22 KB), so a smaller head truncates it, parse yields - // nothing, and the session mis-sniffs as Claude — desyncing dir vs trash counts. - let recs = parse_lines(&read_head(file, 131072)); - // Imported codex COPIES carry the flag in-file like Claude sessions (see set_ccbud) — - // only live rollouts (no .import.json) use the sidecar. - let kind = if crate::codex::looks_codex(&recs) && read_import_meta(&file.to_string_lossy()).is_none() { - DelKind::Codex + // Foreign sources are recognized by path shape alone — no read needed. + let kind = if let Some(fk) = foreign_kind(file) { + DelKind::Foreign(fk) } else { - DelKind::Claude(read_ccbud(&recs).2) + // Read the same window session_meta uses: a Codex rollout's first (session_meta) line + // embeds the full system prompt (~22 KB), so a smaller head truncates it, parse yields + // nothing, and the session mis-sniffs as Claude — desyncing dir vs trash counts. + let recs = parse_lines(&read_head(file, 131072)); + // Imported codex COPIES carry the flag in-file like Claude sessions (see set_ccbud) — + // only live rollouts (no .import.json) use the sidecar. + if crate::codex::looks_codex(&recs) && read_import_meta(&file.to_string_lossy()).is_none() { + DelKind::Codex + } else { + DelKind::Claude(read_ccbud(&recs).2) + } }; if let Ok(mut cache) = deleted_cache().lock() { cache.insert(file.to_path_buf(), (mt, kind)); @@ -223,6 +253,9 @@ fn is_session_deleted(file: &Path) -> bool { match kind { DelKind::Claude(del) => del, DelKind::Codex => crate::codex::is_deleted(file), + DelKind::Foreign(Foreign::Grok) => crate::grok::is_deleted(file), + DelKind::Foreign(Foreign::Copilot) => crate::copilot::is_deleted(file), + DelKind::Foreign(Foreign::Antigravity) => crate::antigravity::is_deleted(file), } } @@ -257,6 +290,44 @@ struct Shaped { last_ts: Option, } +/// The renderer's normalized session shape shared by every non-Claude source (Codex, Grok, +/// Copilot, Antigravity): Anthropic-style messages (`role` + content blocks of +/// text/thinking/tool_use/tool_result) plus the session-level facts each format can recover. +pub struct Norm { + pub messages: Vec, + pub totals: Value, + pub model: Option, + pub first_ts: Option, + pub last_ts: Option, + pub cwd: Option, + pub session_id: Option, + pub git_branch: Option, + pub version: Option, +} + +impl Default for Norm { + fn default() -> Self { + Norm { + messages: vec![], + totals: json!({ "in": 0, "out": 0, "cacheRead": 0, "cacheCreation": 0, "turns": 0 }), + model: None, + first_ts: None, + last_ts: None, + cwd: None, + session_id: None, + git_branch: None, + version: None, + } + } +} + +/// data-URL image → Claude-style image source block, else None. +pub(crate) fn image_block(url: &str) -> Option { + let rest = url.strip_prefix("data:")?; + let (mime, b64) = rest.split_once(";base64,")?; + Some(json!({ "type": "image", "source": { "type": "base64", "media_type": mime, "data": b64 } })) +} + fn shape_messages(recs: &[Value]) -> Shaped { let mut messages = vec![]; let (mut tin, mut tout, mut tcr, mut tcc, mut turns) = (0i64, 0i64, 0i64, 0i64, 0i64); @@ -362,19 +433,27 @@ fn all_dirs(config: &Value) -> Vec<(String, String, PathBuf)> { dirs.push(("__imported__".to_string(), "导入".to_string(), imports_root().join("projects"))); dirs } -/// A dir entry's Codex data tree: sibling `sessions/` next to its `projects/` — every work dir -/// is probed for BOTH layouts (Claude Code writes `/projects/…`, Codex `/sessions/…`), -/// so `~/.codex` is just another configured dir rather than a special case. +/// A sibling data tree next to a dir entry's `projects/`. Every configured dir is probed for +/// EVERY layout (Claude Code writes `/projects/…`, Codex and Grok `/sessions/…`, +/// Copilot `/session-state/…`, Antigravity `/conversations/*.db`), so `~/.codex`, +/// `~/.grok`, `~/.copilot`, `~/.gemini/antigravity-cli` are just configured dirs rather than +/// special cases. +fn sibling_dir(projects_dir: &Path, name: &str) -> Option { + projects_dir.parent().map(|b| b.join(name)) +} + fn sessions_dir(projects_dir: &Path) -> Option { - projects_dir.parent().map(|b| b.join("sessions")) + sibling_dir(projects_dir, "sessions") } -/// Dirs to watch for live history changes — each work dir's projects/ AND sessions/ tree. +/// Dirs to watch for live history changes — each work dir's data trees (all four layouts). pub fn watch_roots(config: &Value) -> Vec { let mut roots: Vec = vec![]; for (_, _, pd) in all_dirs(config) { - if let Some(sd) = sessions_dir(&pd) { - roots.push(sd); + for name in ["sessions", "session-state", "conversations"] { + if let Some(sd) = sibling_dir(&pd, name) { + roots.push(sd); + } } roots.push(pd); } @@ -404,9 +483,32 @@ fn each_session_file(config: &Value, mut } } } - // Codex rollouts live in a date-sharded sessions/ tree, so it gets its own walk. + // Codex rollouts live in a date-sharded sessions/ tree; Grok shares the same sessions/ + // root but keys children by percent-encoded cwd (and stuffs sidecar jsonl — events/ + // updates/rewind — beside each chat_history.jsonl), so children are routed one by one + // rather than letting the codex walker sweep grok trees into garbage rows. if let Some(sd) = sessions_dir(&root) { - crate::codex::walk_sessions(&sd, |p| cb(p, String::new(), &id, &label)); + if let Ok(children) = fs::read_dir(&sd) { + for ent in children.flatten() { + let p = ent.path(); + let name = ent.file_name().to_string_lossy().into_owned(); + if p.is_dir() && crate::grok::is_cwd_dir_name(&name) { + crate::grok::walk_cwd_dir(&p, &mut |f| cb(f, String::new(), &id, &label)); + } else if p.is_dir() { + crate::codex::walk_sessions(&p, |f| cb(f, String::new(), &id, &label)); + } else if p.is_file() && p.extension().and_then(|e| e.to_str()) == Some("jsonl") { + cb(p, String::new(), &id, &label); + } + } + } + } + // Copilot session logs (flat .jsonl + /events.jsonl). + if let Some(ss) = sibling_dir(&root, "session-state") { + crate::copilot::walk(&ss, &mut |f| cb(f, String::new(), &id, &label)); + } + // Antigravity conversations (one SQLite per session). + if let Some(cd) = sibling_dir(&root, "conversations") { + crate::antigravity::walk(&cd, &mut |f| cb(f, String::new(), &id, &label)); } } } @@ -422,8 +524,17 @@ fn meta_cache() -> &'static std::sync::Mutex f64 { + match foreign_kind(file) { + Some(Foreign::Antigravity) => crate::antigravity::wal_mtime_ms(file), + _ => mtime_ms(file), + } +} + fn session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> Option { - let (mt, size) = (mtime_ms(file), fs::metadata(file).ok()?.len()); + let (mt, size) = (cache_stamp_ms(file), fs::metadata(file).ok()?.len()); if let Ok(cache) = meta_cache().lock() { if let Some((cmt, csz, v)) = cache.get(file) { if *cmt == mt && *csz == size { @@ -439,6 +550,17 @@ fn session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> O } fn build_session_meta(file: &Path, dir_name: &str, dir_id: &str, dir_label: &str) -> Option { + // Foreign sources first — routed by container shape BEFORE any content read (one of them + // isn't even text), each through its own shaper. + match foreign_kind(file) { + Some(Foreign::Grok) => return crate::grok::session_meta_from(file, dir_id, dir_label), + Some(Foreign::Copilot) => { + let recs = parse_lines(&read_head(file, 131072)); + return crate::copilot::session_meta_from(file, &recs, dir_id, dir_label); + } + Some(Foreign::Antigravity) => return crate::antigravity::session_meta_from(file, dir_id, dir_label), + None => {} + } let meta = fs::metadata(file).ok()?; let size = meta.len(); let head = read_head(file, 131072); @@ -573,8 +695,12 @@ pub fn dir_stats(config: &Value) -> Vec { let mut out: Vec = all_dirs(config) .into_iter() .map(|(id, label, pd)| { - // A dir "exists" when EITHER data tree is on disk — ~/.codex has only sessions/. - let exists = pd.is_dir() || sessions_dir(&pd).map(|s| s.is_dir()).unwrap_or(false); + // A dir "exists" when ANY data tree is on disk — ~/.codex has only sessions/, + // ~/.copilot only session-state/, ~/.gemini/antigravity-cli only conversations/. + let exists = pd.is_dir() + || ["sessions", "session-state", "conversations"] + .iter() + .any(|n| sibling_dir(&pd, n).map(|s| s.is_dir()).unwrap_or(false)); let imported = id == "__imported__"; json!({ "id": id.clone(), "label": label, "projectsDir": pd.to_string_lossy(), @@ -741,6 +867,23 @@ pub(crate) fn read_import_meta(file: &str) -> Option { pub fn get_session(file: &str) -> Value { let path = Path::new(file); + // Foreign sources route by container shape BEFORE the text read — Antigravity sessions are + // SQLite, and grok/copilot jsonl would otherwise fall through to the Claude shaper. + match foreign_kind(path) { + Some(Foreign::Antigravity) => return crate::antigravity::session_from(file), + Some(fk) => { + let raw = match fs::read_to_string(path) { + Ok(s) => s, + Err(_) => return Value::Null, + }; + let recs = parse_lines(&raw); + return match fk { + Foreign::Grok => crate::grok::session_from_recs(file, &recs), + _ => crate::copilot::session_from_recs(file, &recs), + }; + } + None => {} + } let raw = match fs::read_to_string(path) { Ok(s) => s, Err(_) => return Value::Null, @@ -964,7 +1107,7 @@ const SEARCH_CACHE_BUDGET: usize = 128 * 1024 * 1024; /// parses candidates — files that can't match are neither parsed nor cached. fn thread_scan(path: &Path, q: &str, raw_safe: bool) -> Option<(std::sync::Arc, usize)> { let meta = fs::metadata(path).ok()?; - let (mt, sz) = (mtime_ms(path), meta.len()); + let (mt, sz) = (cache_stamp_ms(path), meta.len()); if let Ok(cache) = search_cache().lock() { if let Some((cmt, csz, text)) = cache.map.get(path) { if *cmt == mt && *csz == sz { @@ -974,19 +1117,32 @@ fn thread_scan(path: &Path, q: &str, raw_safe: bool) -> Option<(std::sync::Arc = if crate::codex::looks_codex(&recs) { - crate::codex::session_from_recs(&path.to_string_lossy(), &recs) - .get("messages") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default() + let fk = foreign_kind(path); + let messages: Vec = if fk == Some(Foreign::Antigravity) { + // SQLite source: no raw-bytes prefilter (the payloads are binary) — extraction is + // cached, so the decode is paid once per file version. + crate::antigravity::normalize_db(path).messages } else { - shape_messages(&recs).messages + let raw = fs::read_to_string(path).ok()?; + if raw_safe && ifind(&raw, q, 0).is_none() { + return None; + } + let recs = parse_lines(&raw); + match fk { + Some(Foreign::Grok) => crate::grok::normalize(&recs, None).messages, + Some(Foreign::Copilot) => crate::copilot::normalize(&recs).messages, + _ => { + if crate::codex::looks_codex(&recs) { + crate::codex::session_from_recs(&path.to_string_lossy(), &recs) + .get("messages") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + } else { + shape_messages(&recs).messages + } + } + } }; let text = std::sync::Arc::new(extract_search_text(&messages)); if let Ok(mut cache) = search_cache().lock() { @@ -1136,6 +1292,21 @@ pub fn set_ccbud(file: &str, patch: &Value, config: &Value) -> Value { if !within_scope(target, config) { return json!({ "ok": false, "reason": "out-of-scope" }); } + // Foreign-CLI sessions are other tools' files (one is SQLite): their title/tags/delete + // flag always live in the app-owned sidecar. Same cache-drop contract as the codex branch. + if let Some(fk) = foreign_kind(target) { + let r = match fk { + Foreign::Grok => crate::grok::set_meta(file, patch), + Foreign::Copilot => crate::copilot::set_meta(file, patch), + Foreign::Antigravity => crate::antigravity::set_meta(file, patch), + }; + if r.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { + if let Ok(mut cache) = meta_cache().lock() { + cache.remove(target); + } + } + return r; + } let raw = match fs::read_to_string(file) { Ok(s) => s, Err(_) => return json!({ "ok": false, "reason": "read" }), @@ -1240,7 +1411,10 @@ pub fn set_ccbud(file: &str, patch: &Value, config: &Value) -> Value { /// (projects/ AND sessions/) plus the imports store. fn within_scope(target: &Path, config: &Value) -> bool { all_dirs(config).iter().any(|(_, _, pd)| { - target.starts_with(pd) || sessions_dir(pd).map(|sd| target.starts_with(sd)).unwrap_or(false) + target.starts_with(pd) + || ["sessions", "session-state", "conversations"] + .iter() + .any(|n| sibling_dir(pd, n).map(|sd| target.starts_with(sd)).unwrap_or(false)) }) } @@ -1252,10 +1426,14 @@ pub fn delete_session_file(file: &str, config: &Value) -> Value { if !target.is_file() { return json!({ "ok": false, "reason": "missing" }); } - // A LIVE Codex rollout is another tool's file — the app only ever soft-deletes it via the - // sidecar and never rewrites it (see set_ccbud), so "delete forever" must not rm the source - // either. Imported codex COPIES (marked by an .import.json) are our own snapshots and stay - // hard-deletable, like Claude sessions the app manages in the configured dirs. + // A LIVE Codex rollout or foreign-CLI session is another tool's file — the app only ever + // soft-deletes those via the sidecar and never rewrites them (see set_ccbud), so "delete + // forever" must not rm the source either. Imported codex COPIES (marked by an .import.json) + // are our own snapshots and stay hard-deletable, like Claude sessions the app manages in + // the configured dirs. + if foreign_kind(target).is_some() { + return json!({ "ok": false, "reason": "foreign" }); + } let head = parse_lines(&read_head(target, 131072)); if crate::codex::looks_codex(&head) && read_import_meta(file).is_none() { return json!({ "ok": false, "reason": "foreign" }); @@ -1306,6 +1484,75 @@ pub fn history_selftest(base_dir: &Path) -> Value { }) } +#[cfg(test)] +mod foreign_probe { + use super::*; + + // Diagnostic harness (not an assertion): list + open REAL foreign-CLI sessions so the + // shapers can be eyeballed against live ~/.grok, ~/.copilot, ~/.gemini/antigravity-cli. + // Run: CCBUD_PROBE_FOREIGN="~/.grok,~/.copilot,~/.gemini/antigravity-cli" \ + // cargo test --lib probe_foreign_dirs -- --ignored --nocapture + #[test] + #[ignore] + fn probe_foreign_dirs() { + let Ok(dirs) = std::env::var("CCBUD_PROBE_FOREIGN") else { + eprintln!("set CCBUD_PROBE_FOREIGN=dir1,dir2,…"); + return; + }; + let list: Vec<&str> = dirs.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect(); + let config = json!({ "historyDirs": list }); + let sessions = list_sessions(&config, "all", 500); + eprintln!("== {} sessions across {:?}", sessions.len(), list); + let mut by_source: std::collections::HashMap = std::collections::HashMap::new(); + for s in &sessions { + *by_source + .entry(s.get("source").and_then(|v| v.as_str()).unwrap_or("?").to_string()) + .or_insert(0) += 1; + } + eprintln!("== by source: {:?}", by_source); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for s in &sessions { + let src = s.get("source").and_then(|v| v.as_str()).unwrap_or("?").to_string(); + if !seen.insert(src.clone()) { + continue; + } + let file = s.get("file").and_then(|v| v.as_str()).unwrap_or(""); + eprintln!( + "-- [{}] {} | cwd={} | title={:?}", + src, + file, + s.get("cwd").and_then(|v| v.as_str()).unwrap_or("-"), + s.get("title").and_then(|v| v.as_str()).unwrap_or("-") + ); + let detail = get_session(file); + let meta = detail.get("meta").cloned().unwrap_or(Value::Null); + let msgs = detail.get("messages").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); + eprintln!( + " detail: assistant={:?} messages={} totals={} firstTs={:?}", + meta.get("assistant").and_then(|v| v.as_str()), + msgs, + meta.get("totals").map(|t| t.to_string()).unwrap_or_default(), + meta.get("firstTs").and_then(|v| v.as_str()) + ); + if let Some(arr) = detail.get("messages").and_then(|v| v.as_array()) { + for m in arr.iter().take(4) { + let role = m.get("role").and_then(|v| v.as_str()).unwrap_or("?"); + let kinds: Vec = m + .get("content") + .and_then(|c| c.as_array()) + .map(|a| { + a.iter() + .map(|b| b.get("type").and_then(|t| t.as_str()).unwrap_or("?").to_string()) + .collect() + }) + .unwrap_or_default(); + eprintln!(" msg {} {:?}", role, kinds); + } + } + } + } +} + // ---- import (copy someone else's .jsonl into the app-managed store) ---- fn encode_cwd(cwd: Option<&str>) -> String { @@ -1405,17 +1652,44 @@ fn write_imported(raw: &str, original_path: &str, original_name: &str, subagents } /// Import a plain .jsonl transcript, bringing along its on-disk subagents dir if present. +/// Foreign-CLI sources (Grok / Copilot / Antigravity) are intentionally not importable — +/// their layouts/formats aren't Claude/Codex, and a Grok chat_history head would otherwise +/// trip looks_codex (its `reasoning` lines look like old envelope-less Codex items). fn import_one(src: &str) -> i32 { + let src_path = Path::new(src); + if foreign_kind(src_path).is_some() { + return 0; + } let raw = match fs::read_to_string(src) { Ok(s) => s, Err(_) => return 0, }; - let src_path = Path::new(src); + // Path-less copies of foreign transcripts: refuse anything whose head sniffs as Grok + // chat_history (type:system + later reasoning/tool_result) or Copilot events + // (type:session.start with producer copilot-agent). + let head: Vec = raw.lines().take(8).filter_map(|l| serde_json::from_str(l.trim()).ok()).collect(); + if looks_foreign_jsonl(&head) { + return 0; + } let subs = read_subagent_files(src_path); let original_name = src_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); write_imported(&raw, src, original_name, &subs) } +/// Content sniff for foreign CLI jsonl (used by import when the path no longer carries the +/// original container shape — e.g. a bare chat_history.jsonl dropped into the import dialog). +fn looks_foreign_jsonl(recs: &[Value]) -> bool { + recs.iter().take(8).any(|r| match r.get("type").and_then(|v| v.as_str()) { + // Copilot event stream + Some("session.start") | Some("user.message") | Some("assistant.message") + | Some("tool.execution_complete") | Some("tool.execution_start") => true, + // Grok chat_history: top-level system/reasoning/tool_result (Claude wraps these) + Some("reasoning") | Some("tool_result") if r.get("message").is_none() => true, + Some("system") if r.get("content").is_some() && r.get("message").is_none() => true, + _ => false, + }) +} + /// Import a conversation-bundle .zip (main session + `subagents/`), restoring the subagent layout so /// the pipeline nests them exactly as if they'd been captured live. Round-trips export_bundle. fn import_zip(src: &str) -> i32 { @@ -1528,6 +1802,148 @@ pub fn import_selftest(base_dir: &Path) -> Value { mod tests { use super::*; + // One work dir carrying ALL foreign layouts (grok sessions/%2F…, copilot session-state/, + // antigravity conversations/*.db): each session must list under its own source with cwd, + // title and detail routed through its shaper, hard-delete must refuse, and content search + // must reach every format. + #[test] + fn foreign_sources_route_end_to_end() { + let base = std::env::temp_dir().join("ccbud-foreign-route-test"); + let _ = fs::remove_dir_all(&base); + + // grok: sessions///chat_history.jsonl + summary.json + let gdir = base.join("sessions").join("%2Ftmp%2Fgproj").join("0199-grok-uuid"); + fs::create_dir_all(&gdir).unwrap(); + fs::write( + gdir.join("chat_history.jsonl"), + "{\"type\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"grok needle walrus\"}]}\n\ + {\"type\":\"assistant\",\"content\":\"done\",\"tool_calls\":[{\"id\":\"c1\",\"name\":\"run_terminal_command\",\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}]}\n", + ) + .unwrap(); + fs::write( + gdir.join("summary.json"), + "{\"info\":{\"id\":\"0199-grok-uuid\",\"cwd\":\"/tmp/gproj\"},\"generated_title\":\"Grok 会话\",\"created_at\":\"2026-06-18T06:27:07.777Z\",\"current_model_id\":\"grok-build\"}", + ) + .unwrap(); + // …and a stray sidecar jsonl the codex walker must NOT sweep into a session row + fs::write(gdir.join("events.jsonl"), "{\"ts\":\"x\",\"type\":\"mcp_config_resolved\"}\n").unwrap(); + + // copilot: session-state//events.jsonl + workspace.yaml + let cdir = base.join("session-state").join("cp-uuid-1"); + fs::create_dir_all(&cdir).unwrap(); + fs::write( + cdir.join("events.jsonl"), + "{\"type\":\"session.start\",\"data\":{\"sessionId\":\"cp-uuid-1\",\"context\":{\"cwd\":\"/tmp/cproj\"}},\"timestamp\":\"2026-07-12T07:26:54.363Z\"}\n\ + {\"type\":\"user.message\",\"data\":{\"content\":\"copilot needle pelican\"},\"timestamp\":\"2026-07-12T07:27:14.463Z\"}\n", + ) + .unwrap(); + fs::write( + cdir.join("workspace.yaml"), + "id: cp-uuid-1\ncwd: /tmp/cproj\nname: Copilot 会话\ncreated_at: 2026-07-12T07:26:54.368Z\n", + ) + .unwrap(); + + // antigravity: conversations/.db with one user step (hand-encoded wire format) + let adir = base.join("conversations"); + fs::create_dir_all(&adir).unwrap(); + let adb = adir.join("agy-uuid-1.db"); + { + fn enc_varint(mut v: u64, out: &mut Vec) { + loop { + let b = (v & 0x7f) as u8; + v >>= 7; + if v == 0 { + out.push(b); + break; + } + out.push(b | 0x80); + } + } + fn put_varint(field: u32, v: u64, out: &mut Vec) { + enc_varint(((field as u64) << 3) | 0, out); + enc_varint(v, out); + } + fn put_bytes(field: u32, data: &[u8], out: &mut Vec) { + enc_varint(((field as u64) << 3) | 2, out); + enc_varint(data.len() as u64, out); + out.extend_from_slice(data); + } + let mut ts = vec![]; + put_varint(1, 1_783_811_237, &mut ts); + let mut meta5 = vec![]; + put_bytes(1, &ts, &mut meta5); + let mut u19 = vec![]; + put_bytes(2, "agy needle capybara".as_bytes(), &mut u19); + let mut step = vec![]; + put_varint(1, 14, &mut step); + put_varint(4, 3, &mut step); + put_bytes(5, &meta5, &mut step); + put_bytes(19, &u19, &mut step); + let conn = rusqlite::Connection::open(&adb).unwrap(); + conn.execute_batch( + "CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER NOT NULL DEFAULT 0, status INTEGER NOT NULL DEFAULT 0, step_payload BLOB);", + ) + .unwrap(); + conn.execute("INSERT INTO steps (idx, step_type, status, step_payload) VALUES (0, 14, 3, ?1)", [&step]) + .unwrap(); + } + { + let conn = rusqlite::Connection::open(base.join("conversation_summaries.db")).unwrap(); + conn.execute_batch( + "CREATE TABLE conversation_summaries (conversation_id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', preview TEXT NOT NULL DEFAULT '', step_count INTEGER NOT NULL DEFAULT 0, last_modified_time DATETIME, workspace_uris TEXT NOT NULL DEFAULT '[]');", + ) + .unwrap(); + conn.execute( + "INSERT INTO conversation_summaries (conversation_id, title, preview, step_count, workspace_uris) VALUES ('agy-uuid-1', 'Agy 会话', 'p', 1, '[\"file:///tmp/aproj\"]')", + [], + ) + .unwrap(); + } + + let config = json!({ "historyDirs": [ base.to_string_lossy() ] }); + let rows = list_sessions(&config, "all", 50); + let by = |src: &str| { + rows.iter() + .find(|r| r.get("source").and_then(|v| v.as_str()) == Some(src)) + .unwrap_or_else(|| panic!("no {} row in {:?}", src, rows)) + .clone() + }; + // exactly one row per source — the grok dir's stray events.jsonl must not add a fourth + assert_eq!(rows.len(), 3, "rows: {:?}", rows); + let (g, c, a) = (by("grok"), by("copilot"), by("antigravity")); + assert_eq!(g["cwd"], "/tmp/gproj"); + assert_eq!(g["title"], "Grok 会话"); + assert_eq!(g["model"], "grok-build"); + assert_eq!(c["cwd"], "/tmp/cproj"); + assert_eq!(c["title"], "Copilot 会话"); + assert_eq!(a["cwd"], "/tmp/aproj"); + assert_eq!(a["title"], "Agy 会话"); + + // detail routes through each shaper (assistant name is the renderer's header/stat hook) + for (row, assistant, first_text) in [ + (&g, "Grok", "grok needle walrus"), + (&c, "Copilot", "copilot needle pelican"), + (&a, "Antigravity", "agy needle capybara"), + ] { + let file = row["file"].as_str().unwrap(); + let d = get_session(file); + assert_eq!(d["meta"]["assistant"], assistant); + assert_eq!(d["messages"][0]["content"][0]["text"], first_text); + // another tool's live file: delete-forever must refuse and leave it on disk + let del = delete_session_file(file, &config); + assert_eq!(del["reason"], "foreign"); + assert!(Path::new(file).is_file()); + } + + // content search reaches every format (agy has no raw-text prefilter path) + for needle in ["walrus", "pelican", "capybara"] { + let hits = search_sessions(&config, "all", needle, 10); + assert_eq!(hits.len(), 1, "search {}: {:?}", needle, hits); + } + + let _ = fs::remove_dir_all(&base); + } + // A live Codex rollout (a work dir's sessions/ tree, no .import.json) must NEVER be hard-deleted // by "delete forever" — it's another tool's file. delete_session_file must refuse and leave it on // disk. A Claude session in the same dir's projects/ tree is still deletable. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6246c6..769889e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,16 +5,20 @@ // popover, updater, and self-check hooks. #![allow(unused_variables)] +mod antigravity; mod claude; mod claudedesktop; mod codex; mod codexconnect; +mod copilot; mod counttokens; mod exporthtml; mod gateway; +mod grok; mod history; mod plugin; mod protocol; +mod sidecar; mod store; mod usage; mod ziputil; @@ -1149,6 +1153,29 @@ fn history_delete_forever(app: tauri::AppHandle, file: String) -> Value { #[tauri::command] async fn history_export_raw(file: String) -> Result { let base = exporthtml::export_base_name(&file); + // Antigravity sessions are SQLite DBs (not text) — export the raw bytes as .db so the + // original conversation remains intact. Other foreign sources and Claude/Codex stay + // verbatim text (.jsonl); sessions with subagents keep the existing zip bundle. + let path = std::path::Path::new(&file); + if matches!( + history::foreign_kind(path), + Some(history::Foreign::Antigravity) + ) { + let bytes = std::fs::read(&file).map_err(|e| e.to_string())?; + return match rfd::AsyncFileDialog::new() + .add_filter("SQLite", &["db"]) + .set_file_name(format!("{}.db", base)) + .save_file() + .await + { + Some(d) => { + let p = d.path().to_path_buf(); + std::fs::write(&p, bytes).map_err(|e| e.to_string())?; + Ok(json!({ "canceled": false, "path": p.to_string_lossy(), "bundled": false })) + } + None => Ok(json!({ "canceled": true })), + }; + } // A session with subagents exports as a .zip bundle (main .jsonl at the top level + subagents/); // a plain session stays a verbatim .jsonl. import_paths accepts either. if history::session_has_subagents(&file) { @@ -1834,11 +1861,14 @@ pub fn run() { .build(), )?; } - // One-time migrations: a detected Codex install (~/.codex/sessions) and an XDG - // Claude tree (~/.config/claude/projects) join historyDirs as regular work dirs. - // Runs BEFORE the history watcher so their trees get watched. + // One-time migrations: detected installs of the other coding CLIs (Codex, Grok + // Build, Copilot CLI, Antigravity CLI) and an XDG Claude tree join historyDirs as + // regular work dirs. Runs BEFORE the history watcher so their trees get watched. store::ensure_codex_dir(); store::ensure_xdg_claude_dir(); + store::ensure_grok_dir(); + store::ensure_copilot_dir(); + store::ensure_antigravity_dir(); // Start the localhost gateway on the configured port (proxy.js parity). let gw = gateway::GatewayState::new(app.handle().clone()); diff --git a/src-tauri/src/sidecar.rs b/src-tauri/src/sidecar.rs new file mode 100644 index 0000000..755cf58 --- /dev/null +++ b/src-tauri/src/sidecar.rs @@ -0,0 +1,204 @@ +// Shared JSON sidecar for per-session customization (title / tags / soft-delete) of sessions +// whose source files the app must never rewrite — Codex rollouts and the foreign-CLI stores +// (Grok / Copilot / Antigravity). One JSON map per store file: { "": {title?, tagList?, +// delete?} }, atomic tmp+rename writes, mtime-keyed process cache. Codex keeps its historical +// codex-meta.json (keys = rollout file stems); the newer CLIs share agent-meta.json with +// ":" keys (their on-disk names — chat_history/events — aren't unique). + +use serde_json::{json, Map, Value}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +pub fn codex_file() -> PathBuf { + crate::store::ccbud_home().join("codex-meta.json") +} + +pub fn agent_file() -> PathBuf { + crate::store::ccbud_home().join("agent-meta.json") +} + +fn cache() -> &'static std::sync::Mutex)>> { + static CACHE: std::sync::OnceLock)>>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())) +} + +fn mtime(path: &PathBuf) -> f64 { + fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as f64) + .unwrap_or(0.0) +} + +fn read(path: &PathBuf) -> Map { + let mt = mtime(path); + if let Ok(guard) = cache().lock() { + if let Some((cmt, map)) = guard.get(path) { + if *cmt == mt { + return map.clone(); + } + } + } + let map = fs::read_to_string(path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + if let Ok(mut guard) = cache().lock() { + guard.insert(path.clone(), (mt, map.clone())); + } + map +} + +fn write(path: &PathBuf, map: &Map) -> bool { + let dir = match path.parent() { + Some(d) => d.to_path_buf(), + None => return false, + }; + let _ = fs::create_dir_all(&dir); + let tmp = path.with_extension("json.tmp"); + let bytes = match serde_json::to_vec_pretty(&Value::Object(map.clone())) { + Ok(b) => b, + Err(_) => return false, + }; + if fs::write(&tmp, bytes).is_err() { + return false; + } + if fs::rename(&tmp, path).is_err() { + let _ = fs::remove_file(&tmp); + return false; + } + if let Ok(mut guard) = cache().lock() { + guard.insert(path.clone(), (mtime(path), map.clone())); + } + true +} + +/// (custom title, tags, deleted) for one key. +pub fn meta(path: &PathBuf, key: &str) -> (Option, Vec, bool) { + let map = read(path); + let c = match map.get(key) { + Some(v) => v, + None => return (None, vec![], false), + }; + let title = c + .get("title") + .and_then(|t| t.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let tags = c + .get("tagList") + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|t| t.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }) + .unwrap_or_default(); + let deleted = c.get("delete").and_then(|v| v.as_bool()).unwrap_or(false); + (title, tags, deleted) +} + +/// set_ccbud-equivalent patch ({title?, tags?, delete?}) applied to one key. +pub fn set_meta(path: &PathBuf, key: &str, patch: &Value) -> Value { + if key.is_empty() { + return json!({ "ok": false, "reason": "empty" }); + } + let mut map = read(path); + let mut next = map + .get(key) + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + if let Some(t) = patch.get("title") { + let t = t.as_str().unwrap_or("").trim().to_string(); + if !t.is_empty() { + next.insert("title".into(), json!(t)); + } else { + next.remove("title"); + } + } + if let Some(tags) = patch.get("tags") { + let mut arr: Vec = vec![]; + if let Some(ta) = tags.as_array() { + for x in ta { + if let Some(s) = x.as_str() { + let s = s.trim(); + if !s.is_empty() && !arr.iter().any(|y| y == s) { + arr.push(s.to_string()); + } + } + } + } + if !arr.is_empty() { + next.insert("tagList".into(), json!(arr)); + } else { + next.remove("tagList"); + } + } + if let Some(d) = patch.get("delete") { + if d.as_bool().unwrap_or(false) { + next.insert("delete".into(), json!(true)); + } else { + next.remove("delete"); + } + } + if next.is_empty() { + map.remove(key); + } else { + map.insert(key.to_string(), Value::Object(next)); + } + if write(path, &map) { + json!({ "ok": true }) + } else { + json!({ "ok": false, "reason": "write" }) + } +} + +/// Drop one key's entry (after its session is deleted forever). +pub fn remove_meta(path: &PathBuf, key: &str) { + let mut map = read(path); + if map.remove(key).is_some() { + let _ = write(path, &map); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_meta_per_key() { + let path = std::env::temp_dir().join("ccbud-sidecar-test").join("agent-meta.json"); + let _ = fs::remove_file(&path); + + assert_eq!(meta(&path, "grok:u1"), (None, vec![], false)); + let r = set_meta(&path, "grok:u1", &json!({ "title": "改个名", "tags": ["a", "a", "b"] })); + assert_eq!(r["ok"], true); + let r = set_meta(&path, "copilot:u2", &json!({ "delete": true })); + assert_eq!(r["ok"], true); + + let (title, tags, deleted) = meta(&path, "grok:u1"); + assert_eq!(title.as_deref(), Some("改个名")); + assert_eq!(tags, vec!["a", "b"]); + assert!(!deleted); + assert!(meta(&path, "copilot:u2").2); + // keys are namespaced per source — same uuid under another source stays untouched + assert_eq!(meta(&path, "antigravity:u1"), (None, vec![], false)); + + // restore drops the flag; emptying every field drops the key wholesale + set_meta(&path, "copilot:u2", &json!({ "delete": false })); + assert!(!meta(&path, "copilot:u2").2); + set_meta(&path, "grok:u1", &json!({ "title": "", "tags": [] })); + assert_eq!(meta(&path, "grok:u1"), (None, vec![], false)); + + remove_meta(&path, "copilot:u2"); + assert_eq!(meta(&path, "copilot:u2"), (None, vec![], false)); + assert_eq!(set_meta(&path, "", &json!({ "title": "x" }))["ok"], false); + } +} diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index e1e439b..01a360b 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -385,6 +385,48 @@ pub fn ensure_codex_dir() -> bool { true } +/// Shared body of the ensure_*_dir migrations: when `exists` and the run-once `flag` hasn't +/// fired, add `label` to historyDirs (dedup) and set the flag. Returns true when the config +/// changed (caller refreshes the history views). A user who later REMOVES the dir isn't +/// fighting an auto-re-add; a missing install keeps probing on future launches. +fn ensure_history_dir(flag: &str, exists: bool, label: String) -> bool { + let mut cfg = read_config(); + if cfg.get(flag).and_then(|v| v.as_bool()).unwrap_or(false) { + return false; + } + if !exists { + return false; // nothing there yet — keep probing on future launches + } + let obj = cfg.as_object_mut().unwrap(); + let mut dirs: Vec = obj + .get("historyDirs") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + if !dirs.iter().any(|d| *d == label) { + dirs.push(label); + } + obj.insert("historyDirs".into(), json!(dirs)); + obj.insert(flag.into(), json!(true)); + write_config(cfg); + true +} + +/// One-time startup migrations for the other coding CLIs whose sessions the 对话 view can +/// browse: Grok Build (~/.grok, GROK_HOME-aware), GitHub Copilot CLI (~/.copilot), and the +/// Antigravity CLI (~/.gemini/antigravity-cli). Same run-once contract as ensure_codex_dir. +pub fn ensure_grok_dir() -> bool { + ensure_history_dir("grokDirAutoAdded", crate::grok::root_exists(), crate::grok::grok_label()) +} + +pub fn ensure_copilot_dir() -> bool { + ensure_history_dir("copilotDirAutoAdded", crate::copilot::root_exists(), crate::copilot::copilot_label()) +} + +pub fn ensure_antigravity_dir() -> bool { + ensure_history_dir("antigravityDirAutoAdded", crate::antigravity::root_exists(), crate::antigravity::agy_label()) +} + /// One-time startup migration (ccusage parity): Claude Code also writes history under the XDG /// config dir (`$XDG_CONFIG_HOME/claude`, default `~/.config/claude`) — when that tree exists, /// add it to historyDirs so its sessions count toward conversations and usage. Same run-once diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 76f1ebd..c4c536a 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -464,6 +464,13 @@ fn codex_files(root: &Path) -> Vec { collect_jsonl(&dir, 0, &mut files); files.sort(); for f in files { + // Grok Build shares the sessions/ root but keys children by percent-encoded cwd + // (`%2FUsers%2F…//chat_history.jsonl` + events/updates sidecar jsonl). Those + // must never hit the Codex token parser — wasteful and would mix formats if a line + // ever looked like a token_count event. Skip any path under a Grok-encoded dir. + if f.components().any(|c| crate::grok::is_cwd_dir_name(&c.as_os_str().to_string_lossy())) { + continue; + } let rel = f.strip_prefix(&dir).map(|p| p.to_path_buf()).unwrap_or_else(|_| f.clone()); if seen_rel.insert(rel) { out.push(f); diff --git a/src/renderer/conversations.js b/src/renderer/conversations.js index 5932139..4088e8b 100644 --- a/src/renderer/conversations.js +++ b/src/renderer/conversations.js @@ -13,6 +13,10 @@ const midEllip = (s, max) => { s = String(s == null ? '' : s); if (s.length <= max) return s; const k = max - 1, h = Math.ceil(k / 2), t = Math.floor(k / 2); return s.slice(0, h) + '…' + s.slice(s.length - t); }; const ICN = window.ccbudIcons || {}; // SVG icon set (icons.js loads before this script) const localeTag = () => (window.I18n ? window.I18n.localeTag : 'en-US'); + // Non-Claude session sources (meta.source): list-row chip label + assistant display name. + // Claude ('disk') deliberately has no chip — it's the app's home turf. + const SOURCE_NAMES = { codex: 'Codex', grok: 'Grok', copilot: 'Copilot', antigravity: 'Antigravity' }; + const isForeignSource = (s) => !!SOURCE_NAMES[s]; let projects = []; // [{ cwd, name, sessions:[...], lastActivity }] let openId = null; @@ -474,12 +478,16 @@ const live = isLive(c.lastActivity) ? '' : ''; const sub = c.isSubagent ? `${esc(L('conv.subagent'))}` : ''; const imp = c.imported ? `${ICN.download || ''}${esc(L('conv.imported'))}` : ''; + // Non-Claude sources carry a small origin chip so a mixed project group stays readable. + const srcName = SOURCE_NAMES[c.source]; + const srcBadge = srcName ? `${esc(srcName)}` : ''; // Recycle bin rows swap the import-remove affordance for restore + delete-forever; everywhere else // imported copies (which live only in the app store) keep their remove affordance. const inTrash = activeDir === '__trash__'; - // A LIVE Codex session (source codex, not an imported copy) is another tool's file — it can be - // restored but NEVER permanently deleted, since the app must not rm ~/.codex's rollouts. - const foreign = c.source === 'codex' && !c.imported; + // A LIVE session of another CLI (codex/grok/copilot/antigravity, not an imported copy) is + // that tool's file — it can be restored but NEVER permanently deleted, since the app must + // not rm another tool's data. + const foreign = isForeignSource(c.source) && !c.imported; const restoreBtn = ``; const deleteForeverBtn = ``; const rm = inTrash @@ -502,7 +510,7 @@ const tip = (c.autoTitle && c.title && c.autoTitle !== c.title) ? (fullTitle + ' · ' + c.autoTitle) : fullTitle; return `
${live}${esc(fullTitle)}${rm}
-
${model}${sub}${imp}
+
${model}${srcBadge}${sub}${imp}
${snipRow} ${tagsRow}
${metaTimes(c)}${c.sizeKB ? '' + fmtSizeKB(c.sizeKB) + '' : ''}
@@ -1072,7 +1080,7 @@ [L('conv.stat.input'), t.in != null ? fmtTok(t.in) : null], [L('conv.stat.output'), t.out != null ? fmtTok(t.out) : null], [L('conv.stat.cacheRead'), t.cacheRead ? fmtTok(t.cacheRead) : null], - [L('conv.stat.tool'), m.assistant === 'Codex' ? 'Codex' : 'Claude Code'], + [L('conv.stat.tool'), m.assistant || 'Claude Code'], [L('conv.stat.version'), m.version], ].filter((r) => r[1] != null && r[1] !== ''); $('convStats').innerHTML = rows.map((r) => `
${esc(r[0])}${esc(r[1])}
`).join(''); @@ -1343,10 +1351,10 @@ }); } ctxMenuEl._file = file; ctxMenuEl._id = id; - // A live Codex session can be restored but never permanently deleted (its rollout is another - // tool's file); imported copies live in our store and keep delete-forever. + // A live session of another CLI can be restored but never permanently deleted (the file + // belongs to that tool); imported copies live in our store and keep delete-forever. const s = findSession(id, file); - const foreign = s && s.source === 'codex' && !s.imported; + const foreign = s && isForeignSource(s.source) && !s.imported; // Recycle-bin rows offer restore / delete-forever; everywhere else it's rename / add-tag / delete. ctxMenuEl.innerHTML = (activeDir === '__trash__') ? `` + From 9deae039355190fe04cc6bbebc7a25ad3c2f2b8f Mon Sep 17 00:00:00 2001 From: LoadChange Date: Mon, 20 Jul 2026 19:22:49 +0800 Subject: [PATCH 2/5] feat: remove Claude Desktop pane and add Sessions message text-size setting --- src-tauri/src/claudedesktop.rs | 226 --------------------------------- src-tauri/src/gateway.rs | 17 ++- src-tauri/src/lib.rs | 45 +------ src/renderer/analytics.js | 1 - src/renderer/icons.js | 1 - src/renderer/index.html | 46 ++++--- src/renderer/input.css | 44 ++++--- src/renderer/renderer.js | 147 ++++++++++----------- src/renderer/styles.css | 49 ++++--- src/renderer/tauri-bridge.js | 3 - src/shared/i18n-dict.js | 149 +++++++--------------- 11 files changed, 214 insertions(+), 514 deletions(-) delete mode 100644 src-tauri/src/claudedesktop.rs diff --git a/src-tauri/src/claudedesktop.rs b/src-tauri/src/claudedesktop.rs deleted file mode 100644 index ff3c5c7..0000000 --- a/src-tauri/src/claudedesktop.rs +++ /dev/null @@ -1,226 +0,0 @@ -// Claude Desktop "Third-Party Inference" integration — Rust port of claudeDesktop.js. -// -// Claude Desktop reads inference settings from macOS Managed Preferences, delivered as a -// Configuration Profile (.mobileconfig). connect() generates the profile pre-filled with the -// local gateway and opens it for the user to approve; disconnect() removes it via an admin -// prompt. The profile advertises the claude-* tier models (matching /v1/models) so a fresh -// Claude Desktop can pick a model and drive the gateway with zero per-user setup. - -#![allow(dead_code)] - -use crate::gateway::CLAUDE_TIER_MODELS; -use serde_json::{json, Value}; -use std::path::PathBuf; - -const BUNDLE_ID: &str = "com.anthropic.claudefordesktop"; -const PROFILE_IDENTIFIER: &str = "dev.ccbud.gateway.claude-desktop-inference"; -const PROFILES_PANE: &str = - "x-apple.systempreferences:com.apple.preferences.configurationprofiles"; - -fn home() -> PathBuf { - std::env::var("HOME").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from(".")) -} -fn is_mac() -> bool { - cfg!(target_os = "macos") -} -fn endpoint(port: u16) -> String { - format!("http://localhost:{}", port) -} -pub fn profile_path() -> PathBuf { - home().join(".ccbud").join("claude-desktop-inference.mobileconfig") -} - -fn uuid_from(seed: &str) -> String { - use sha1::{Digest, Sha1}; - let mut h = Sha1::new(); - h.update(seed.as_bytes()); - let hex = format!("{:x}", h.finalize()); - format!("{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32]).to_uppercase() -} -fn xml_esc(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">") -} - -fn app_installed() -> bool { - if !is_mac() { - return false; - } - [ - PathBuf::from("/Applications/Claude.app"), - home().join("Applications").join("Claude.app"), - home().join("Library").join("Application Support").join("Claude"), - ] - .iter() - .any(|p| p.exists()) -} - -pub fn build_profile(port: u16, token: &str) -> String { - // Gateway picker needs an explicit model list as a SINGLE JSON string; names carry Anthropic - // keywords (so its validation accepts them) and match what /v1/models returns. - let models: Vec = CLAUDE_TIER_MODELS - .iter() - .map(|(name, tier)| { - let mut m = serde_json::Map::new(); - m.insert("name".into(), json!(name)); - m.insert("anthropicFamilyTier".into(), json!(tier)); - if *tier == "sonnet" { - m.insert("isFamilyDefault".into(), json!(true)); - } - Value::Object(m) - }) - .collect(); - let inference_models = serde_json::to_string(&models).unwrap_or_default(); - - let settings = [ - ("inferenceProvider", "gateway".to_string()), - ("inferenceCredentialKind", "static".to_string()), - ("inferenceGatewayBaseUrl", endpoint(port)), - ("inferenceGatewayApiKey", if token.is_empty() { "ccbud-local".to_string() } else { token.to_string() }), - ("inferenceGatewayAuthScheme", "bearer".to_string()), - ("inferenceModels", inference_models), - ]; - let body = settings - .iter() - .map(|(k, v)| format!(" {}\n {}", k, xml_esc(v))) - .collect::>() - .join("\n"); - - format!( - r#" - - - - PayloadContent - - - PayloadType - {bundle} - PayloadIdentifier - {ident}.settings - PayloadUUID - {uuid_settings} - PayloadVersion - 1 - PayloadDisplayName - Claude Desktop Third-Party Inference (CC Buddy) -{body} - - - PayloadDisplayName - CC Buddy · Claude Desktop 第三方推理 - PayloadDescription - 将 Claude 桌面版的模型推理指向本地 CC Buddy 网关({ep})。可随时移除以还原为官方推理。 - PayloadIdentifier - {ident} - PayloadOrganization - CC Buddy - PayloadRemovalDisallowed - - PayloadScope - User - PayloadType - Configuration - PayloadUUID - {uuid_root} - PayloadVersion - 1 - - -"#, - bundle = BUNDLE_ID, - ident = PROFILE_IDENTIFIER, - uuid_settings = uuid_from(&format!("{}.settings", PROFILE_IDENTIFIER)), - uuid_root = uuid_from(PROFILE_IDENTIFIER), - body = body, - ep = endpoint(port), - ) -} - -fn managed_base_url() -> Option { - if !is_mac() { - return None; - } - let user = std::env::var("USER").unwrap_or_default(); - let mut paths = vec![format!("/Library/Managed Preferences/{}.plist", BUNDLE_ID)]; - if !user.is_empty() { - paths.push(format!("/Library/Managed Preferences/{}/{}.plist", user, BUNDLE_ID)); - } - for p in paths { - if !std::path::Path::new(&p).exists() { - continue; - } - if let Ok(out) = std::process::Command::new("/usr/bin/plutil") - .args(["-extract", "inferenceGatewayBaseUrl", "raw", "-o", "-", &p]) - .output() - { - let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !s.is_empty() { - return Some(s); - } - } - } - None -} - -pub fn status(port: u16) -> Value { - json!({ - "supported": is_mac(), - "installed": app_installed(), - "connected": is_mac() && managed_base_url().as_deref() == Some(endpoint(port).as_str()), - "endpoint": endpoint(port), - }) -} - -pub fn connect(port: u16, token: &str) -> Value { - if !is_mac() { - return json!({ "ok": false, "reason": "unsupported" }); - } - if !app_installed() { - return json!({ "ok": false, "reason": "notInstalled" }); - } - let file = profile_path(); - if let Some(dir) = file.parent() { - let _ = std::fs::create_dir_all(dir); - } - if std::fs::write(&file, build_profile(port, token)).is_err() { - return json!({ "ok": false, "reason": "write" }); - } - let f = file.to_string_lossy().to_string(); - let _ = std::process::Command::new("/usr/bin/open").arg(&f).spawn(); - // Take the user to System Settings › Profiles shortly after, so they can approve it (matches - // claudeDesktop.js — without this many users get stuck not knowing where to approve). - std::thread::spawn(|| { - std::thread::sleep(std::time::Duration::from_millis(1200)); - let _ = std::process::Command::new("/usr/bin/open").arg(PROFILES_PANE).spawn(); - }); - json!({ "ok": true, "needsApproval": true, "path": f }) -} - -pub fn disconnect() -> Value { - if !is_mac() { - return json!({ "ok": false, "reason": "unsupported" }); - } - let osa = format!( - "do shell script \"/usr/bin/profiles remove -identifier {}\" with administrator privileges", - PROFILE_IDENTIFIER - ); - match std::process::Command::new("/usr/bin/osascript").args(["-e", &osa]).output() { - Ok(o) if o.status.success() => json!({ "ok": true, "removed": true }), - Ok(o) => { - // User canceled the admin-password prompt (-128 / "User canceled") → report it as - // cancelled instead of pretending it still needs approval. Otherwise (CLI unavailable) - // fall back to opening System Settings so the user can remove it manually. - let stderr = String::from_utf8_lossy(&o.stderr); - if stderr.contains("-128") || stderr.to_lowercase().contains("user canceled") { - json!({ "ok": false, "cancelled": true }) - } else { - let _ = std::process::Command::new("/usr/bin/open").arg(PROFILES_PANE).spawn(); - json!({ "ok": true, "removed": false, "needsApproval": true }) - } - } - Err(_) => { - let _ = std::process::Command::new("/usr/bin/open").arg(PROFILES_PANE).spawn(); - json!({ "ok": true, "removed": false, "needsApproval": true }) - } - } -} diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index 8623d90..d201c78 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -25,14 +25,13 @@ use tokio::sync::{oneshot, Mutex}; use crate::protocol::codex_history::{HistoryResolution, ResponseOrigin}; use crate::store; -/// Default Claude tier models ccbud advertises to Claude-family clients (Claude Code, -/// Claude Desktop). Second field is the Claude Desktop `anthropicFamilyTier` keyword. -pub const CLAUDE_TIER_MODELS: &[(&str, &str)] = &[ - ("claude-fable-5", "opus"), - ("claude-opus-4-8", "opus"), - ("claude-sonnet-5", "sonnet"), - ("claude-haiku-4-5", "haiku"), - ("claude-haiku-4-5-20251001", "haiku"), +/// Default Claude tier models ccbud advertises to Claude-family clients (Claude Code). +pub const CLAUDE_TIER_MODELS: &[&str] = &[ + "claude-fable-5", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-haiku-4-5", + "claude-haiku-4-5-20251001", ]; /// Stable Codex model identities advertised by the gateway. These names are understood by the @@ -1117,7 +1116,7 @@ fn tier_entries(is_codex: bool) -> Vec { if is_codex { CODEX_TIER_MODELS.iter().map(|n| model_entry(n)).collect() } else { - CLAUDE_TIER_MODELS.iter().map(|(n, _)| model_entry(n)).collect() + CLAUDE_TIER_MODELS.iter().map(|n| model_entry(n)).collect() } } fn merge_models(upstream: &Value, config: &Value, is_codex: bool) -> Value { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 769889e..fa6128c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,7 +7,6 @@ mod antigravity; mod claude; -mod claudedesktop; mod codex; mod codexconnect; mod copilot; @@ -420,7 +419,7 @@ async fn provider_test(app: tauri::AppHandle, p: Value) -> Value { } } -// ---- claude code / desktop integration ---- +// ---- coding CLI connect / replay ---- /// The literal selected CLIs from config `connectTargets` (subset of {claude, codex}, deduped). /// Empty is a valid state ("nothing connected") — the hero Connect button substitutes a default. fn connect_targets(cfg: &Value) -> Vec { @@ -604,32 +603,6 @@ async fn set_connect_target( refresh_tray_menu(&app); Ok(json!({ "ok": true })) } -#[tauri::command] -async fn desktop_status( - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - let port = gw - .current_port() - .await - .unwrap_or_else(|| store::read_config().get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16); - Ok(claudedesktop::status(port)) -} -#[tauri::command] -async fn desktop_connect( - gw: tauri::State<'_, std::sync::Arc>, -) -> Result { - let cfg = store::read_config(); - let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(8788) as u16; - if cfg.get("providers").and_then(|v| v.as_array()).map(|a| a.is_empty()).unwrap_or(true) { - return Ok(json!({ "ok": false, "reason": "noProvider" })); - } - let _ = gw.start(port).await; - Ok(claudedesktop::connect(port, &claude::current_token(&cfg))) -} -#[tauri::command] -fn desktop_disconnect() -> Value { - claudedesktop::disconnect() -} fn pct(s: &str) -> String { s.bytes() .map(|b| match b { @@ -1565,17 +1538,6 @@ fn selfcheck_history() -> Value { history::history_selftest(&store::ccbud_home()) } #[tauri::command] -fn selfcheck_desktop() -> Value { - let p = claudedesktop::build_profile(18799, "tok"); - json!({ - "hasBaseUrl": p.contains("http://localhost:18799"), - "hasProvider": p.contains("inferenceProvider") && p.contains("gateway"), - "hasModels": p.contains("claude-sonnet-5") && p.contains("anthropicFamilyTier") && p.contains("isFamilyDefault"), - "hasBundleId": p.contains("com.anthropic.claudefordesktop"), - "validXml": p.starts_with(""), - }) -} -#[tauri::command] fn selfcheck_import() -> Value { history::import_selftest(&store::ccbud_home()) } @@ -1727,7 +1689,6 @@ const SELFCHECK_JS: &str = r#" try{ var cc=await window.ccbud.connect(); var s1=await window.ccbud.serverStatus(); var dd=await window.ccbud.disconnect(); var s2=await window.ccbud.serverStatus(); o.claude={connOk:cc&&cc.ok,connected:s1.connected,discOk:dd&&dd.ok,afterDisc:s2.connected}; }catch(e){ o.claudeErr=String(e); } try{ o.copyOk=await window.ccbud.copy('selfcheck-clip'); }catch(e){ o.copyErr=String(e); } try{ o.histMeta=await window.__TAURI__.core.invoke('selfcheck_history'); }catch(e){ o.histMetaErr=String(e); } - try{ o.desktop=await window.__TAURI__.core.invoke('selfcheck_desktop'); }catch(e){ o.desktopErr=String(e); } try{ o.export=await window.__TAURI__.core.invoke('selfcheck_export'); }catch(e){ o.exportErr=String(e); } try{ o.import=await window.__TAURI__.core.invoke('selfcheck_import'); }catch(e){ o.importErr=String(e); } try{ var us=await window.ccbud.updateState(); var sa=await window.ccbud.updateSetAuto({check:false}); o.update={current:us.current,status:us.status,setAutoCheck:sa.check}; }catch(e){ o.updateErr=String(e); } @@ -2273,14 +2234,14 @@ pub fn run() { plugin_action, plugin_action_load, plugin_install, plugin_uninstall, plugin_open_dir, plugin_install_git, plugin_check_update, plugin_update, - claude_connect, claude_disconnect, set_connect_target, desktop_status, desktop_connect, desktop_disconnect, desktop_replay, chatgpt_replay, + claude_connect, claude_disconnect, set_connect_target, desktop_replay, chatgpt_replay, server_status, gateway_set_enabled, usage_get, monitor_get, monitor_clear, logs_get, logs_clear, app_open_main, app_quit, window_settings_mode, window_view_min_width, history_projects, history_list, history_get, history_search, history_dirs, history_pick_dir, history_set_active, history_import, history_import_paths, history_remove_import, history_set_meta, history_delete_forever, history_export_raw, history_export_html, util_copy, util_open_external, update_state, update_check, update_download, update_apply, update_set_auto, - selfcheck_report, selfcheck_routing, selfcheck_gateway, selfcheck_history, selfcheck_desktop, selfcheck_export, selfcheck_import, selfcheck_popover + selfcheck_report, selfcheck_routing, selfcheck_gateway, selfcheck_history, selfcheck_export, selfcheck_import, selfcheck_popover ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src/renderer/analytics.js b/src/renderer/analytics.js index d0ac47f..87caf41 100644 --- a/src/renderer/analytics.js +++ b/src/renderer/analytics.js @@ -96,7 +96,6 @@ var FUNNEL = { btnConnect: 'connect-toggle', popConnect: 'connect-toggle', - btnDesktopConnect: 'desktop-connect', btnAdd: 'provider-add', btnAddEmpty: 'provider-add', btnSave: 'provider-save', diff --git a/src/renderer/icons.js b/src/renderer/icons.js index a57d8a4..f4d4f91 100644 --- a/src/renderer/icons.js +++ b/src/renderer/icons.js @@ -49,7 +49,6 @@ window.ccbudIcons = { /* settings sub-nav */ gateway: '', - desktop: '', shield: '', diff --git a/src/renderer/index.html b/src/renderer/index.html index 3460186..dfe7c6f 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -179,10 +179,6 @@

网关 - - -

- -

把 Claude 桌面版的模型推理也指向本地网关,和 Claude Code 共用同一套服务与模型映射。

-

macOS 要求你在「系统设置 › 描述文件」里手动批准一次(输管理员密码)。这不是高危操作 —— 只是把推理指向本地网关,系统出于安全要求必须你亲自确认。还原同理。

- - -