use anyhow::{Context, Result};
use cchb::app::{self, AppState};
use cchb::color::Theme;
use cchb::{event, session, ui};
use crossterm::ExecutableCommand;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::Terminal;
use ratatui::prelude::CrosstermBackend;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
fn get_claude_dir() -> Result<PathBuf> {
let home = directories::BaseDirs::new().context("Failed to determine home directory")?;
let claude_dir = home.home_dir().join(".claude");
if !claude_dir.exists() {
anyhow::bail!(
"Claude Code data directory not found: {}\nMake sure Claude Code has been used at least once.",
claude_dir.display()
);
}
Ok(claude_dir)
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
terminal::enable_raw_mode().context("Failed to enable raw mode")?;
io::stdout()
.execute(EnterAlternateScreen)
.context("Failed to enter alternate screen")?;
io::stdout()
.execute(EnableMouseCapture)
.context("Failed to enable mouse capture")?;
let backend = CrosstermBackend::new(io::stdout());
let terminal = Terminal::new(backend).context("Failed to create terminal")?;
Ok(terminal)
}
fn restore_terminal() {
let _ = io::stdout().execute(DisableMouseCapture);
let _ = terminal::disable_raw_mode();
let _ = io::stdout().execute(LeaveAlternateScreen);
}
fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut AppState,
theme: &Theme,
claude_dir: &Path,
) -> Result<()> {
app.maybe_load_focused_conversation();
loop {
terminal.draw(|frame| {
ui::render(frame, app, theme);
})?;
while app.resolve_pending_search_jump() {
app.maybe_load_focused_conversation();
terminal.draw(|frame| {
ui::render(frame, app, theme);
})?;
}
if app.poll_session_loading() {
app.maybe_load_focused_conversation();
}
app.poll_search_cache();
app.check_reload_expired();
app.check_clipboard_flash_expired();
let poll_ms = if app.logo_sparkle_start.is_some() {
50
} else {
250
};
if crossterm::event::poll(Duration::from_millis(poll_ms))? {
match crossterm::event::read()? {
Event::Key(key) => {
if key.kind != KeyEventKind::Press {
continue;
}
if app.mode == app::AppMode::Normal
&& key.code == crossterm::event::KeyCode::Char('R')
{
if let Ok(sessions) = session::discover_sessions(claude_dir) {
let indices: Vec<usize> = (0..sessions.len()).collect();
app.sessions = sessions;
app.filtered_indices = indices;
app.selected_index = 0;
app.loaded_session_id = None;
app.invalidate_search_content_cache();
}
continue;
}
event::handle_key(app, key)?;
}
Event::Mouse(mouse) => {
event::handle_mouse(app, mouse);
}
_ => continue,
}
app.maybe_load_focused_conversation();
if app.should_quit {
break;
}
}
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
enum CliAction {
Run,
PrintVersion,
}
fn parse_cli_args(args: &[String]) -> CliAction {
for arg in args.iter().skip(1) {
if arg == "--version" || arg == "-v" {
return CliAction::PrintVersion;
}
}
CliAction::Run
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
if parse_cli_args(&args) == CliAction::PrintVersion {
println!("cchb {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
restore_terminal();
original_hook(panic_info);
}));
let claude_dir = get_claude_dir()?;
let (tx, rx) = mpsc::channel();
let claude_dir_bg = claude_dir.clone();
std::thread::spawn(move || {
let sessions = session::discover_sessions(&claude_dir_bg).unwrap_or_default();
let _ = tx.send(sessions);
});
let mut app = AppState::loading();
app.session_receiver = Some(rx);
let theme = Theme::default_theme();
let mut terminal = setup_terminal()?;
let result = run_app(&mut terminal, &mut app, &theme, &claude_dir);
restore_terminal();
result?;
if let Some(session_id) = &app.resume_session_id {
let mut cmd = std::process::Command::new("claude");
cmd.args(["--resume", session_id]);
if let Some(ref project_path) = app.resume_project_path {
let path = std::path::Path::new(project_path);
if !path.is_dir() {
std::fs::create_dir_all(path).ok();
}
if path.is_dir() {
cmd.current_dir(path);
}
}
let status = cmd
.status()
.context("Failed to launch claude. Is it installed and in your PATH?")?;
if !status.success() {
anyhow::bail!("claude exited with status: {status}");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn args(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn parse_no_args_returns_run() {
assert_eq!(parse_cli_args(&args(&["cchb"])), CliAction::Run);
}
#[test]
fn parse_long_version_flag_returns_print_version() {
assert_eq!(
parse_cli_args(&args(&["cchb", "--version"])),
CliAction::PrintVersion
);
}
#[test]
fn parse_short_version_flag_returns_print_version() {
assert_eq!(
parse_cli_args(&args(&["cchb", "-v"])),
CliAction::PrintVersion
);
}
#[test]
fn parse_unknown_arg_falls_through_to_run() {
assert_eq!(
parse_cli_args(&args(&["cchb", "--unknown"])),
CliAction::Run
);
}
}