mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
ed269cc20e
Previously Codex and OpenClaw sessions only showed the working directory basename as the title, making it hard to distinguish sessions in the same project. Now both providers extract the first real user message as the session title, matching the existing Claude Code behavior. - Codex: first user message → dir basename (skips AGENTS.md injection) - OpenClaw: displayName (sessions.json) → first user message → dir basename - Move TITLE_MAX_CHARS constant to shared utils.rs - Use Option<&HashMap> for OpenClaw parse_session to avoid leaky abstraction
178 lines
5.3 KiB
Rust
178 lines
5.3 KiB
Rust
use std::fs::File;
|
|
use std::io::{self, BufRead, BufReader, Seek, SeekFrom};
|
|
use std::path::Path;
|
|
|
|
use chrono::{DateTime, FixedOffset};
|
|
use serde_json::Value;
|
|
|
|
/// Maximum number of characters for session titles (shared across providers).
|
|
pub const TITLE_MAX_CHARS: usize = 80;
|
|
|
|
/// Read the first `head_n` lines and last `tail_n` lines from a file.
|
|
/// For small files (< 16 KB), reads all lines once to avoid unnecessary seeking.
|
|
pub fn read_head_tail_lines(
|
|
path: &Path,
|
|
head_n: usize,
|
|
tail_n: usize,
|
|
) -> io::Result<(Vec<String>, Vec<String>)> {
|
|
let file = File::open(path)?;
|
|
let file_len = file.metadata()?.len();
|
|
|
|
// For small files, read all lines once and split
|
|
if file_len < 16_384 {
|
|
let reader = BufReader::new(file);
|
|
let all: Vec<String> = reader.lines().map_while(Result::ok).collect();
|
|
let head = all.iter().take(head_n).cloned().collect();
|
|
let skip = all.len().saturating_sub(tail_n);
|
|
let tail = all.into_iter().skip(skip).collect();
|
|
return Ok((head, tail));
|
|
}
|
|
|
|
// Read head lines from the beginning
|
|
let reader = BufReader::new(file);
|
|
let head: Vec<String> = reader.lines().take(head_n).map_while(Result::ok).collect();
|
|
|
|
// Seek to last ~16 KB for tail lines
|
|
let seek_pos = file_len.saturating_sub(16_384);
|
|
let mut file2 = File::open(path)?;
|
|
file2.seek(SeekFrom::Start(seek_pos))?;
|
|
let tail_reader = BufReader::new(file2);
|
|
let all_tail: Vec<String> = tail_reader.lines().map_while(Result::ok).collect();
|
|
|
|
// Skip first partial line if we seeked into the middle of a line
|
|
let skip_first = if seek_pos > 0 { 1 } else { 0 };
|
|
let usable: Vec<String> = all_tail.into_iter().skip(skip_first).collect();
|
|
let skip = usable.len().saturating_sub(tail_n);
|
|
let tail = usable.into_iter().skip(skip).collect();
|
|
|
|
Ok((head, tail))
|
|
}
|
|
|
|
pub fn parse_timestamp_to_ms(value: &Value) -> Option<i64> {
|
|
// Integer: milliseconds (>1e12) or seconds
|
|
if let Some(n) = value.as_i64() {
|
|
return Some(if n > 1_000_000_000_000 { n } else { n * 1000 });
|
|
}
|
|
if let Some(n) = value.as_f64() {
|
|
let n = n as i64;
|
|
return Some(if n > 1_000_000_000_000 { n } else { n * 1000 });
|
|
}
|
|
// RFC3339 string
|
|
let raw = value.as_str()?;
|
|
DateTime::parse_from_rfc3339(raw)
|
|
.ok()
|
|
.map(|dt: DateTime<FixedOffset>| dt.timestamp_millis())
|
|
}
|
|
|
|
pub fn extract_text(content: &Value) -> String {
|
|
match content {
|
|
Value::String(text) => text.to_string(),
|
|
Value::Array(items) => items
|
|
.iter()
|
|
.filter_map(extract_text_from_item)
|
|
.filter(|text| !text.trim().is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
Value::Object(map) => map
|
|
.get("text")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or_default()
|
|
.to_string(),
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
fn extract_text_from_item(item: &Value) -> Option<String> {
|
|
let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
|
|
|
|
// tool_use: show tool name
|
|
if item_type == "tool_use" {
|
|
let name = item
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("unknown");
|
|
return Some(format!("[Tool: {name}]"));
|
|
}
|
|
|
|
// tool_result: extract nested content
|
|
if item_type == "tool_result" {
|
|
if let Some(content) = item.get("content") {
|
|
let text = extract_text(content);
|
|
if !text.is_empty() {
|
|
return Some(text);
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
|
|
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
|
|
return Some(text.to_string());
|
|
}
|
|
|
|
if let Some(text) = item.get("input_text").and_then(|v| v.as_str()) {
|
|
return Some(text.to_string());
|
|
}
|
|
|
|
if let Some(text) = item.get("output_text").and_then(|v| v.as_str()) {
|
|
return Some(text.to_string());
|
|
}
|
|
|
|
if let Some(content) = item.get("content") {
|
|
let text = extract_text(content);
|
|
if !text.is_empty() {
|
|
return Some(text);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub fn truncate_summary(text: &str, max_chars: usize) -> String {
|
|
let trimmed = text.trim();
|
|
if trimmed.is_empty() {
|
|
return String::new();
|
|
}
|
|
if trimmed.chars().count() <= max_chars {
|
|
return trimmed.to_string();
|
|
}
|
|
|
|
let mut result = trimmed.chars().take(max_chars).collect::<String>();
|
|
result.push_str("...");
|
|
result
|
|
}
|
|
|
|
pub fn path_basename(value: &str) -> Option<String> {
|
|
let trimmed = value.trim();
|
|
if trimmed.is_empty() {
|
|
return None;
|
|
}
|
|
let normalized = trimmed.trim_end_matches(['/', '\\']);
|
|
let last = normalized
|
|
.split(['/', '\\'])
|
|
.next_back()
|
|
.filter(|segment| !segment.is_empty())?;
|
|
Some(last.to_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn parse_timestamp_to_ms_supports_integers_and_rfc3339() {
|
|
assert_eq!(
|
|
parse_timestamp_to_ms(&json!(1_771_061_953_033_i64)),
|
|
Some(1_771_061_953_033)
|
|
);
|
|
assert_eq!(
|
|
parse_timestamp_to_ms(&json!(1_771_061_953_i64)),
|
|
Some(1_771_061_953_000)
|
|
);
|
|
assert_eq!(
|
|
parse_timestamp_to_ms(&json!("1970-01-01T00:00:01Z")),
|
|
Some(1_000)
|
|
);
|
|
}
|
|
}
|