Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ homepage = "https://github.com/cc-deck/cc-session"
ratatui = "0.30"
crossterm = "0.29"
clap = { version = "4.5", features = ["derive"] }
arboard = { version = "3.6", features = ["wayland-data-control"] }
rayon = "1.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Expand All @@ -27,6 +26,9 @@ syntect = "5.3"
syntect-tui = "3.0"
termbg = "0.6"

[dev-dependencies]
tempfile = "3"

[profile.release]
lto = true
strip = true
Expand Down
17 changes: 0 additions & 17 deletions src/clipboard.rs

This file was deleted.

97 changes: 83 additions & 14 deletions src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,29 +60,88 @@ pub fn discover_sessions(claude_home: &Path) -> Vec<Session> {
sessions
}

/// Decode a Claude project directory name to a real filesystem path.
///
/// Claude encodes paths by replacing '/' with '-', e.g.:
/// `-Users-abulgu-github-repos-mabulgu-cluster-baremetal-operator`
/// becomes:
/// `/Users/abulgu/github-repos/mabulgu/cluster-baremetal-operator`
///
/// Uses filesystem probing to handle dashes in actual directory names.
fn decode_encoded_dir(encoded: &str) -> String {
let trimmed = encoded.trim_start_matches('-');
let parts: Vec<&str> = trimmed.split('-').collect();
let mut path = PathBuf::from("/");
let mut i = 0;

while i < parts.len() {
let mut found = false;
for j in (i + 1..=parts.len()).rev() {
let candidate = parts[i..j].join("-");
let full = path.join(&candidate);
if full.is_dir() {
path = full;
i = j;
found = true;
break;
}
}
if !found {
path = path.join(parts[i]);
i += 1;
}
}

path.to_string_lossy().to_string()
}

/// Parse a single JSONL session file and extract the first user message.
fn parse_session_file(path: &Path) -> Option<Session> {
let session_id = path.file_stem()?.to_str()?.to_string();

let encoded_dir = path.parent()?.file_name()?.to_str()?.to_string();

let file = fs::File::open(path).ok()?;
let reader = BufReader::new(file);

// Track metadata from the first user entry (for cwd, branch, timestamp)
// but keep scanning for a non-meta message to display
let mut cwd = String::new();
let mut git_branch: Option<String> = None;
let mut timestamp: DateTime<Utc> = Utc::now();
let mut first_message = String::new();
let mut found_metadata = false;
let mut found_message = false;
let mut custom_title: Option<String> = None;
let mut line_count: usize = 0;

for line in reader.lines().take(50) {
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
if line.trim().is_empty() {
continue;
}

if line.contains("\"custom-title\"") {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&line) {
if val.get("type").and_then(|v| v.as_str()) == Some("custom-title") {
custom_title = val
.get("customTitle")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
}
continue;
}

if found_message {
continue;
}
line_count += 1;
if line_count > 50 {
continue;
}

let entry: SessionFileEntry = match serde_json::from_str(&line) {
Ok(e) => e,
Err(_) => continue,
Expand All @@ -92,7 +151,6 @@ fn parse_session_file(path: &Path) -> Option<Session> {
continue;
}

// Grab metadata from the first user entry
if !found_metadata {
cwd = entry.cwd.clone().unwrap_or_default();
git_branch = entry.git_branch.clone();
Expand All @@ -104,7 +162,6 @@ fn parse_session_file(path: &Path) -> Option<Session> {
found_metadata = true;
}

// Extract and clean message text
let raw_text = entry.message.map(|m| m.content.text()).unwrap_or_default();
if is_meta_message(&raw_text) {
continue;
Expand All @@ -118,30 +175,43 @@ fn parse_session_file(path: &Path) -> Option<Session> {
.chars()
.take(200)
.collect();
break;
found_message = true;
}

if !found_metadata {
return None;
}

let project_name = Path::new(&cwd)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let project_name = {
let decoded = decode_encoded_dir(&encoded_dir);
let decoded_path = Path::new(&decoded);
if decoded_path.exists() {
decoded_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
} else {
Path::new(&cwd)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
}
};

let project_exists = Path::new(&cwd).exists();

Some(Session {
id: session_id,
project_path: cwd.clone(),
project_path: encoded_dir,
project_name,
git_branch,
timestamp,
first_message,
cwd,
project_exists,
custom_title,
})
}

Expand All @@ -152,10 +222,9 @@ fn parse_session_file(path: &Path) -> Option<Session> {
/// the same role are merged into a single message with paragraphs separated by
/// blank lines.
pub fn load_conversation(claude_home: &Path, session: &Session) -> Vec<ConversationMessage> {
let encoded_dir = session.project_path.replace('/', "-");
let file_path = claude_home
.join("projects")
.join(&encoded_dir)
.join(&session.project_path)
.join(format!("{}.jsonl", session.id));

let file = match fs::File::open(&file_path) {
Expand Down
5 changes: 3 additions & 2 deletions src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ pub fn filter_sessions(sessions: &[Session], query: &str) -> Vec<usize> {
.enumerate()
.filter_map(|(idx, session)| {
let branch = session.git_branch.as_deref().unwrap_or("");
let title = session.custom_title.as_deref().unwrap_or("");
let haystack = format!(
"{} {} {}",
session.project_name, branch, session.first_message
"{} {} {} {}",
session.project_name, branch, session.first_message, title
)
.to_lowercase();

Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
pub mod clipboard;
pub mod discovery;
pub mod filter;
pub mod search;
pub mod session;
pub mod theme;
pub mod titles;
pub mod tui;
9 changes: 7 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
mod clipboard;
mod discovery;
mod filter;
mod search;
mod session;
mod theme;
mod titles;
mod tui;

use clap::Parser;
Expand Down Expand Up @@ -33,6 +33,10 @@ struct Cli {
/// Force dark color theme
#[arg(long = "dark", conflicts_with = "light")]
dark: bool,

/// Start in flat list view (default: grouped by project)
#[arg(long = "flat")]
flat: bool,
}

/// Parse a human-friendly duration string into a chrono::Duration.
Expand Down Expand Up @@ -95,7 +99,8 @@ fn main() {
};

// Interactive TUI
if let Err(e) = tui::run(sessions, theme) {
let grouped_view = !cli.flat;
if let Err(e) = tui::run(sessions, theme, grouped_view) {
eprintln!("TUI error: {e}");
std::process::exit(1);
}
Expand Down
6 changes: 3 additions & 3 deletions src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,8 @@ pub fn build_session_index(claude_home: &Path, sessions: &[Session]) -> HashMap<
let mut index = HashMap::with_capacity(sessions.len());

for session in sessions {
let encoded_dir = session.project_path.replace('/', "-");
let file_path = projects_dir
.join(&encoded_dir)
.join(&session.project_path)
.join(format!("{}.jsonl", session.id));
index.insert(file_path, session.clone());
}
Expand Down Expand Up @@ -311,12 +310,13 @@ fn search_file_with_metadata(path: &Path, re: &Regex) -> Option<Session> {

Some(Session {
id: session_id,
project_path: cwd.clone(),
project_path: path.parent()?.file_name()?.to_str()?.to_string(),
project_name,
git_branch: entry.git_branch,
timestamp,
first_message,
cwd,
project_exists,
custom_title: None,
})
}
11 changes: 9 additions & 2 deletions src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,24 @@ pub struct Session {
pub first_message: String,
pub cwd: String,
pub project_exists: bool,
pub custom_title: Option<String>,
}

impl Session {
/// Build the shell command to resume this session.
///
/// The path is single-quoted to handle spaces and special characters.
pub fn resume_command(&self) -> String {
// Single-quote the path, escaping any embedded single quotes
let escaped_cwd = self.cwd.replace('\'', "'\\''");
format!("cd '{}' && claude -r {}", escaped_cwd, self.id)
match &self.custom_title {
Some(title) if !title.is_empty() => {
let escaped_title = title.replace('\'', "'\\''");
format!("cd '{}' && claude -r {} -n '{}'", escaped_cwd, self.id, escaped_title)
}
_ => format!("cd '{}' && claude -r {}", escaped_cwd, self.id),
}
Comment on lines 25 to +33
}

}

/// A single line from a session JSONL file.
Expand Down
41 changes: 41 additions & 0 deletions src/titles.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;

use crate::discovery::get_claude_home;

/// Append a custom-title entry to a session's JSONL file.
pub fn save_custom_title(project_path: &str, session_id: &str, title: &str) -> Result<(), String> {
let claude_home = get_claude_home();
let file_path = claude_home
.join("projects")
.join(project_path)
.join(format!("{session_id}.jsonl"));

append_custom_title(&file_path, session_id, title)
}

fn append_custom_title(path: &Path, session_id: &str, title: &str) -> Result<(), String> {
let entry = serde_json::json!({
"type": "custom-title",
"customTitle": title,
"sessionId": session_id,
});

let needs_newline = std::fs::read(path)
.map(|bytes| !bytes.is_empty() && !bytes.ends_with(b"\n"))
.unwrap_or(false);

let mut file = OpenOptions::new()
.append(true)
.open(path)
.map_err(|e| format!("failed to open session file: {e}"))?;

if needs_newline {
file.write_all(b"\n")
.map_err(|e| format!("failed to write newline: {e}"))?;
}

writeln!(file, "{}", entry)
.map_err(|e| format!("failed to write title: {e}"))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading