Files
CC-Switch/src-tauri/src/session_manager/providers/utils.rs
T
Jason ed269cc20e feat(sessions): extract meaningful titles for Codex and OpenClaw sessions
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
2026-04-14 10:33:41 +08:00

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)
);
}
}