mod claude;
mod cli;
mod config;
mod debug;
mod debug_log;
mod display;
mod error;
mod history;
mod markdown;
mod pager;
mod syntax;
mod tool_format;
mod tui;
mod update;
use clap::Parser;
use cli::{Args, Commands};
use error::{AppError, Result};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::Command;
fn main() {
if let Err(e) = run() {
match e {
AppError::SelectionCancelled => {
std::process::exit(0);
}
_ => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
}
fn resolve_bool_setting(
enable_flag: bool,
disable_flag: bool,
config_value: Option<bool>,
default_value: bool,
) -> bool {
if enable_flag {
true
} else if disable_flag {
false
} else {
config_value.unwrap_or(default_value)
}
}
fn run() -> Result<()> {
let args = Args::parse();
if let Some(command) = args.command {
return match command {
Commands::Update => update::run(),
};
}
tui::theme::detect_theme();
let config = config::load_config()?;
let display_config = config.display.unwrap_or_default();
let resume_config = config.resume.unwrap_or_default();
let default_args = resume_config.default_args.as_deref().unwrap_or(&[]);
if args.no_color {
colored::control::set_override(false);
}
let keys = config::KeyBindings::from_config(config.keys);
let show_tools = resolve_bool_setting(
args.show_tools,
args.no_tools,
display_config.no_tools.map(|b| !b),
false, );
let tool_display = if args.show_tools {
tui::ToolDisplayMode::Full
} else if args.no_tools {
tui::ToolDisplayMode::Hidden
} else {
match display_config.no_tools {
Some(true) => tui::ToolDisplayMode::Hidden,
Some(false) => tui::ToolDisplayMode::Full,
None => tui::ToolDisplayMode::Truncated,
}
};
let show_last = resolve_bool_setting(args.last, args.first, display_config.last, true);
let show_thinking = resolve_bool_setting(
args.show_thinking,
args.hide_thinking,
display_config.show_thinking,
false,
);
let plain_mode = resolve_bool_setting(args.plain, false, display_config.plain, false);
let use_pager = resolve_bool_setting(
args.pager,
args.no_pager,
display_config.pager,
std::io::stdout().is_terminal(),
);
if let Some(ref session_id) = args.delete {
match history::delete_session_by_uuid(session_id) {
Ok(count) => {
if count == 1 {
eprintln!("Deleted session {}", session_id);
} else {
eprintln!(
"Deleted session {} ({} copies across projects)",
session_id, count
);
}
return Ok(());
}
Err(e) => return Err(e),
}
}
if let Some(ref query) = args.debug_search {
let mut conversations = history::load_all_conversations(show_last, args.debug)?;
conversations.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
let searchable = tui::search::precompute_search_text(&conversations);
let now = chrono::Local::now();
let query_lower = tui::search::normalize_for_search(query);
let query_words: Vec<&str> = query_lower.split_whitespace().collect();
let adjacent_pairs: Vec<String> = if query_words.len() > 1 {
query_words
.windows(2)
.map(|w| format!("{} {}", w[0], w[1]))
.collect()
} else {
vec![]
};
let current_project_dir_name = if args.local {
std::env::current_dir()
.ok()
.map(|d| history::convert_path_to_project_dir_name(&d))
} else {
None
};
let mut results: Vec<_> = searchable
.iter()
.filter_map(|s| {
if let Some(ref proj) = current_project_dir_name {
let conv = &conversations[s.index];
let matches =
conv.path
.parent()
.and_then(|p| p.file_name())
.is_some_and(|name| {
history::is_same_project(&name.to_string_lossy(), proj)
});
if !matches {
return None;
}
}
let debug = tui::search::score_text_debug(
s,
&conversations[s.index].search_text_lower,
&query_words,
&adjacent_pairs,
conversations[s.index].timestamp,
now,
)?;
Some((s.index, debug))
})
.collect();
results.sort_by(|a, b| {
b.1.total
.partial_cmp(&a.1.total)
.unwrap_or(std::cmp::Ordering::Equal)
});
for (rank, (idx, debug)) in results.iter().take(30).enumerate() {
let conv = &conversations[*idx];
let age = now.signed_duration_since(conv.timestamp);
let project = conv.project_name.as_deref().unwrap_or("(none)");
let age_str = format_debug_age(age);
eprintln!(
"#{:2} score={:.2} freshness={:.2} | {} | {} ago",
rank + 1,
debug.total,
debug.freshness,
project,
age_str
);
for field in &debug.fields {
if field.tf_score > 0.0 || field.adjacency_score > 0.0 {
eprintln!(
" {}: tf={:.2} adj={:.2} (w={:.1})",
field.name, field.tf_score, field.adjacency_score, field.weight
);
for (word, tf, ln_score) in &field.word_details {
if *tf > 0 {
eprintln!(" \"{}\" tf={} ln={:.2}", word, tf, ln_score);
}
}
}
}
eprintln!();
}
return Ok(());
}
if let Some(ref render_path) = args.render {
let display_options = display::DisplayOptions {
no_tools: !show_tools,
show_thinking,
debug_level: args.debug,
use_pager,
no_color: args.no_color,
};
return display::render_to_terminal(render_path, &display_options);
}
if let Some(ref input_file) = args.input_file {
if !input_file.exists() {
return Err(AppError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("File not found: {}", input_file.display()),
)));
}
if !input_file.is_file() {
return Err(AppError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Not a file: {}", input_file.display()),
)));
}
tui::run_single_file(input_file.clone(), tool_display, show_thinking, keys)?;
return Ok(());
}
let use_local = args.local;
let current_dir = std::env::current_dir().ok();
let current_project_dir_name = current_dir
.as_ref()
.map(|d| history::convert_path_to_project_dir_name(d));
if args.show_dir {
if let Some(ref dir) = current_dir {
let projects_dir = history::get_claude_projects_dir(dir)?;
println!("{}", projects_dir.display());
return Ok(());
} else {
return Err(AppError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Failed to get current directory",
)));
}
}
let workspace_filter = use_local;
let rx = history::load_all_conversations_streaming(show_last, args.debug);
let (conversations, selected_path) = match tui::run_with_loader(
rx,
tool_display,
show_thinking,
keys,
workspace_filter,
current_project_dir_name,
)? {
(tui::Action::Select(path), convs) => (convs, path),
(tui::Action::Resume(path), convs) => {
let conv = convs.iter().find(|c| c.path == path);
let project_path = conv.and_then(|c| c.project_path.as_ref());
resume_with_claude(&path, project_path, default_args, false)?;
return Ok(());
}
(tui::Action::ForkResume(path), convs) => {
let conv = convs.iter().find(|c| c.path == path);
let project_path = conv.and_then(|c| c.project_path.as_ref());
resume_with_claude(&path, project_path, default_args, true)?;
return Ok(());
}
(tui::Action::Quit, _) => return Err(AppError::SelectionCancelled),
(tui::Action::Delete(_), _) => unreachable!("Delete is handled internally"),
};
if args.show_path {
println!("{}", selected_path.display());
return Ok(());
}
if args.show_id {
let conversation_id = selected_path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| {
AppError::ClaudeExecutionError(
"Conversation filename is not valid Unicode".to_string(),
)
})?;
println!("{}", conversation_id);
return Ok(());
}
if args.resume {
let conv = conversations.iter().find(|c| c.path == selected_path);
debug::debug(
args.debug,
&format!("Selected path: {}", selected_path.display()),
);
debug::debug(
args.debug,
&format!("Found conversation: {}", conv.is_some()),
);
if let Some(c) = conv {
debug::debug(args.debug, &format!("project_path: {:?}", c.project_path));
if let Some(p) = &c.project_path {
debug::debug(args.debug, &format!("project_path exists: {}", p.exists()));
}
}
let project_path = conv.and_then(|c| c.project_path.as_ref());
resume_with_claude(
&selected_path,
project_path,
default_args,
args.fork_session,
)?;
return Ok(());
}
if args.debug.is_some()
&& let Some(conv) = conversations.iter().find(|c| c.path == selected_path)
{
if let Err(e) = debug_log::log_parse_errors(conv) {
debug::warn(
args.debug,
&format!("Failed to write parse errors to log: {}", e),
);
} else if !conv.parse_errors.is_empty() {
debug::info(
args.debug,
&format!(
"Logged {} parse error(s) to ~/.local/state/claude-history/debug.log",
conv.parse_errors.len()
),
);
}
}
let display_options = display::DisplayOptions {
no_tools: !show_tools,
show_thinking,
debug_level: args.debug,
use_pager,
no_color: args.no_color,
};
if plain_mode {
display::display_conversation_plain(&selected_path, &display_options)?;
} else {
display::display_conversation(&selected_path, &display_options)?;
}
Ok(())
}
fn resume_with_claude(
selected_path: &Path,
project_path: Option<&PathBuf>,
default_args: &[String],
fork_session: bool,
) -> Result<()> {
let conversation_id = selected_path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| {
AppError::ClaudeExecutionError("Conversation filename is not valid Unicode".to_string())
})?
.to_owned();
let project_dir = project_path.filter(|p| p.exists() && p.is_dir());
let cwd = std::env::current_dir().map_err(|e| {
AppError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Failed to get current directory: {}", e),
))
})?;
let conv_projects_dir = selected_path.parent().ok_or_else(|| {
AppError::ClaudeExecutionError(
"Cannot determine conversation's project directory".to_string(),
)
})?;
let needs_copy = if project_dir.is_none() {
true
} else if fork_session {
let cwd_projects_dir = history::get_claude_projects_dir(&cwd)?;
cwd_projects_dir != conv_projects_dir
} else {
false
};
if needs_copy {
let cwd_projects_dir = history::get_claude_projects_dir(&cwd)?;
std::fs::create_dir_all(&cwd_projects_dir).map_err(AppError::Io)?;
copy_session_files(
selected_path,
&conversation_id,
conv_projects_dir,
&cwd_projects_dir,
)?;
let mut command = Command::new("claude");
command.args(["--resume", &conversation_id]);
command.args(default_args);
command.current_dir(&cwd);
return run_claude_command(command);
}
let mut command = Command::new("claude");
command.args(["--resume", &conversation_id]);
if fork_session {
command.arg("--fork-session");
}
command.args(default_args);
command.current_dir(project_dir.unwrap());
run_claude_command(command)
}
fn copy_session_files(
jsonl_path: &Path,
session_id: &str,
source_projects_dir: &Path,
target_projects_dir: &Path,
) -> Result<()> {
let target_jsonl = target_projects_dir.join(jsonl_path.file_name().unwrap());
std::fs::copy(jsonl_path, &target_jsonl).map_err(AppError::Io)?;
let session_dir = source_projects_dir.join(session_id);
if session_dir.is_dir() {
let target_session_dir = target_projects_dir.join(session_id);
copy_dir_recursive(&session_dir, &target_session_dir)?;
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst).map_err(AppError::Io)?;
for entry in std::fs::read_dir(src).map_err(AppError::Io)? {
let entry = entry.map_err(AppError::Io)?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path).map_err(AppError::Io)?;
}
}
Ok(())
}
fn format_debug_age(age: chrono::Duration) -> String {
let hours = age.num_hours();
if hours < 24 {
format!("{}h", hours)
} else {
format!("{}d", hours / 24)
}
}
#[cfg(unix)]
fn run_claude_command(mut command: Command) -> Result<()> {
use std::os::unix::process::CommandExt;
let err = command.exec();
Err(AppError::ClaudeExecutionError(err.to_string()))
}
#[cfg(not(unix))]
fn run_claude_command(mut command: Command) -> Result<()> {
let status = command
.status()
.map_err(|e| AppError::ClaudeExecutionError(e.to_string()))?;
if !status.success() {
return Err(AppError::ClaudeExecutionError(format!(
"claude CLI exited with status {}",
status
)));
}
Ok(())
}