neimar 0.1.0

TUI app for managing multiple AI bot sessions in a terminal
mod app;
mod event;
mod input;
mod mouse;
mod session;
mod types;
mod ui;

use app::App;
use event::apply_event;
use types::{AppEvent, CliType, Focus, MAX_PTY_EVENTS_PER_FRAME};

use crossterm::event::{Event, KeyEventKind};
use std::time::Duration;
use tokio::sync::mpsc;

/// Drain pending keys and PTY events, poll status files, render.
fn process_frame(
    app: &mut App,
    key_rx: &mut mpsc::UnboundedReceiver<Event>,
    rx: &mut mpsc::UnboundedReceiver<AppEvent>,
    terminal: &mut ratatui::DefaultTerminal,
) -> std::io::Result<()> {
    while let Ok(ev) = key_rx.try_recv() {
        match ev {
            Event::Key(key) => app.handle_key(key),
            Event::Mouse(mouse) => app.handle_mouse(mouse),
            _ => {}
        }
        if app.should_quit {
            break;
        }
    }
    for _ in 0..MAX_PTY_EVENTS_PER_FRAME {
        match rx.try_recv() {
            Ok(ev) => apply_event(app, ev),
            Err(_) => break,
        }
    }
    if !app.should_quit {
        app.poll_status_files();
        terminal.draw(|frame| app.render(frame))?;
    }
    Ok(())
}

// ── Main ────────────────────────────────────────────────

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut terminal = ratatui::init();
    let result = run(&mut terminal).await;
    let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
    ratatui::restore();
    result
}

async fn run(terminal: &mut ratatui::DefaultTerminal) -> std::io::Result<()> {
    crossterm::execute!(std::io::stdout(), crossterm::event::EnableMouseCapture)?;

    // Set terminal title to parent/folder name
    if let Ok(cwd) = std::env::current_dir() {
        let mut components = cwd.components().rev();
        let folder = components
            .next()
            .map(|c| c.as_os_str().to_string_lossy().to_string());
        let parent = components
            .next()
            .map(|c| c.as_os_str().to_string_lossy().to_string());
        let title = match (parent, folder) {
            (Some(p), Some(f)) => format!("{}/{}", p, f),
            (None, Some(f)) => f,
            _ => "neimar".to_string(),
        };
        let _ = crossterm::execute!(
            std::io::stdout(),
            crossterm::terminal::SetTitle(&title)
        );
    }

    let (key_tx, mut key_rx) = mpsc::unbounded_channel::<Event>();
    let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();

    // Dedicated OS thread for keyboard/mouse reading — never blocked by tokio scheduler
    std::thread::spawn(move || {
        loop {
            match crossterm::event::read() {
                Ok(Event::Key(key))
                    if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat =>
                {
                    if key_tx.send(Event::Key(key)).is_err() {
                        break;
                    }
                }
                Ok(Event::Mouse(mouse)) => {
                    if key_tx.send(Event::Mouse(mouse)).is_err() {
                        break;
                    }
                }
                Ok(_) => {}
                Err(_) => break,
            }
        }
    });

    let mut app = App::new(tx);
    terminal.draw(|frame| app.render(frame))?;
    let (rows, cols) = if app.layout.last_right_panel_size.0 > 0 {
        app.layout.last_right_panel_size
    } else {
        (24, 80)
    };
    app.create_session("claude skip-perm".to_string(), CliType::ClaudeDangerous, rows, cols);
    app.create_session("claude".to_string(), CliType::Claude, rows, cols);
    app.create_session("console".to_string(), CliType::Console, rows, cols);
    // Focus the first session (claude skip-perm)
    app.list_state.select(Some(0));
    app.ui.focus = Focus::Terminal;
    let mut render_interval = tokio::time::interval(Duration::from_millis(33));
    render_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);

    loop {
        tokio::select! {
            biased;

            // Priority 1: input events (key + mouse)
            Some(ev) = key_rx.recv() => {
                match ev {
                    Event::Key(key) => app.handle_key(key),
                    Event::Mouse(mouse) => app.handle_mouse(mouse),
                    _ => {}
                }
                process_frame(&mut app, &mut key_rx, &mut rx, terminal)?;
                render_interval.reset();
            }

            // Priority 2: render tick
            _ = render_interval.tick() => {
                process_frame(&mut app, &mut key_rx, &mut rx, terminal)?;
            }
        }

        if app.should_quit {
            break;
        }
    }

    app.shutdown();
    Ok(())
}