tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! tazuna CLI entry point
//!
//! TUI tool for managing multiple Claude Code sessions in parallel.

use std::io::{Read, Stdout, stdout};

use anyhow::{Context, Result};
use clap::Parser;
use crossterm::{
    event::{DisableMouseCapture, EnableMouseCapture},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, backend::CrosstermBackend};
use tokio::sync::mpsc;

use tazuna::session::{SessionEvent, SessionId};
use tazuna::tui::TuiApp;
use tazuna::{
    Cli, Commands, Config, HookEvent, HooksClient, HooksServer, NativeGitHubClient, NativePty,
    NativeWorktreeManager, SessionManager, current_socket_path,
};

/// Guard to ensure terminal cleanup on drop
struct TerminalGuard {
    stdout: Stdout,
}

impl TerminalGuard {
    fn new() -> Result<Self> {
        enable_raw_mode()?;
        let mut stdout = stdout();
        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
        Ok(Self { stdout })
    }
}

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
        let _ = execute!(self.stdout, LeaveAlternateScreen, DisableMouseCapture);
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();

    match cli.command {
        Some(Commands::Notify) => run_notify().await,
        None => run_tui().await,
    }
}

/// Run the notify subcommand
///
/// Called by Claude Code hooks. Reads JSON from stdin and sends to main process.
async fn run_notify() -> Result<()> {
    // Read JSON from stdin
    let mut input = String::new();
    std::io::stdin()
        .read_to_string(&mut input)
        .context("failed to read from stdin")?;

    // Parse JSON payload
    let payload: serde_json::Value =
        serde_json::from_str(&input).context("failed to parse JSON from stdin")?;

    // Get session ID from environment
    let session_id_str = std::env::var("TAZUNA_SESSION_ID")
        .context("TAZUNA_SESSION_ID environment variable not set")?;
    let session_id: SessionId = session_id_str
        .parse()
        .context("failed to parse TAZUNA_SESSION_ID")?;

    // Create hook event
    let event =
        HookEvent::from_payload(session_id, payload).context("failed to create hook event")?;

    // Send to main process via Unix socket
    let client = HooksClient::from_env().context("failed to create hooks client")?;
    client
        .send(&event)
        .await
        .context("failed to send hook event")?;

    Ok(())
}

/// Run the TUI application
async fn run_tui() -> Result<()> {
    // Load configuration first (needed for log directory)
    let config = Config::load()?;

    // Initialize file-based logging (avoids TUI corruption from stdout)
    let _log_guard = tazuna::logging::init_file_logging(&config.log_directory())?;

    // Setup channels
    let (event_tx, event_rx) = mpsc::channel(256);
    let (cmd_tx, cmd_rx) = mpsc::channel(64);

    // Setup hooks IPC server
    let (hook_tx, mut hook_rx) = mpsc::channel(64);
    let socket_path = current_socket_path();
    let hooks_server =
        HooksServer::new(&socket_path, hook_tx).context("failed to create hooks server")?;
    tokio::spawn(hooks_server.run());

    // Forward hook events to session event channel
    let event_tx_clone = event_tx.clone();
    tokio::spawn(async move {
        while let Some(event) = hook_rx.recv().await {
            let _ = event_tx_clone
                .send(SessionEvent::HookReceived { event })
                .await;
        }
    });

    // Create PTY handle
    let pty = NativePty::new();

    // Create worktree manager (None if not in git repo)
    // Uses discover() to find main repo root even when started from worktree
    let worktree = NativeWorktreeManager::discover(config.worktree.clone()).ok();

    // Spawn SessionManager in background
    let mut manager = SessionManager::new(
        config.session.max_sessions,
        pty,
        worktree,
        config.worktree.auto_cleanup,
        config.worktree.pull_strategy,
        event_tx,
        NativeGitHubClient,
    );
    manager.set_socket_path(socket_path);
    tokio::spawn(manager.run(cmd_rx));

    // Setup terminal with cleanup guard
    let _guard = TerminalGuard::new()?;
    let backend = CrosstermBackend::new(stdout());
    let mut terminal = Terminal::new(backend)?;

    // Get initial terminal size
    let size = terminal.size()?;

    // Create TUI app
    let mut app = TuiApp::new(
        cmd_tx.clone(),
        config.notification.clone(),
        config.claude.default_args.clone(),
    );
    app.set_size(size.height.saturating_sub(2), size.width); // Account for tab bar and status bar

    // Fetch initial cost from ccusage
    let _ = cmd_tx
        .send(tazuna::session::SessionCommand::FetchCostAsync)
        .await;

    // Spawn periodic cost refresh (every 60s)
    let cmd_tx_cost = cmd_tx.clone();
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
        interval.tick().await; // Skip first immediate tick
        loop {
            interval.tick().await;
            if cmd_tx_cost
                .send(tazuna::session::SessionCommand::FetchCostAsync)
                .await
                .is_err()
            {
                break;
            }
        }
    });

    // Run TUI
    let result = app.run(event_rx, &mut terminal).await;

    // Show cursor before exit
    terminal.show_cursor()?;

    result
}