cchb 0.9.5

A TUI tool for browsing and restoring past Claude Code session history
Documentation
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<()> {
    // Load initial conversation
    app.maybe_load_focused_conversation();

    loop {
        terminal.draw(|frame| {
            ui::render(frame, app, theme);
        })?;

        // Resolve pending cross-session search jumps after render populates match positions.
        while app.resolve_pending_search_jump() {
            app.maybe_load_focused_conversation();
            terminal.draw(|frame| {
                ui::render(frame, app, theme);
            })?;
        }

        // Poll for background session discovery completion.
        if app.poll_session_loading() {
            app.maybe_load_focused_conversation();
        }

        // Poll for background search cache completion.
        app.poll_search_cache();

        // Auto-dismiss reload indicator after timeout.
        app.check_reload_expired();

        // Auto-dismiss clipboard flash indicator after timeout.
        app.check_clipboard_flash_expired();

        // Use shorter poll interval during logo sparkle animation for smooth color cycling.
        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;
                    }

                    // Handle reload (R key in normal mode)
                    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,
            }

            // Auto-load conversation when focus changes
            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(());
    }

    // Set up panic hook to restore terminal
    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()?;

    // Spawn session discovery in a background thread so the UI appears immediately.
    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 user requested session resume, launch claude --resume from the project directory
    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
        );
    }
}