vtcode-tui 0.98.7

Reusable TUI primitives and session API for VT Code-style terminal interfaces
use std::io;

use crate::utils::tty::TtyExt;
use anyhow::{Context, Result, anyhow};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event;
use ratatui::widgets::ListState;

mod input;
mod render;
mod terminal;

#[derive(Debug, Clone)]
pub struct SelectionEntry {
    pub title: String,
    pub description: Option<String>,
}

impl SelectionEntry {
    pub fn new(title: impl Into<String>, description: Option<String>) -> Self {
        Self {
            title: title.into(),
            description,
        }
    }
}

#[derive(Debug, thiserror::Error)]
#[error("selection interrupted by Ctrl+C")]
pub struct SelectionInterrupted;

pub fn run_interactive_selection(
    title: &str,
    instructions: &str,
    entries: &[SelectionEntry],
    default_index: usize,
) -> Result<Option<usize>> {
    if entries.is_empty() {
        return Err(anyhow!("No options available for selection"));
    }

    if !io::stderr().is_tty_ext() {
        return Err(anyhow!("Terminal UI is unavailable"));
    }

    let mut stderr = io::stderr();
    let mut terminal_guard = TerminalModeGuard::new(title);
    terminal_guard.save_cursor_position(&mut stderr);
    terminal_guard.enable_raw_mode()?;
    terminal_guard.enter_alternate_screen(&mut stderr)?;

    let backend = CrosstermBackend::new(stderr);
    let mut terminal = Terminal::new(backend)
        .with_context(|| format!("Failed to initialize Ratatui terminal for {title} selector"))?;
    terminal_guard.hide_cursor(&mut terminal)?;

    let selection_result = (|| -> Result<Option<usize>> {
        let total = entries.len();
        let mut selected_index = default_index.min(total.saturating_sub(1));
        let mut number_buffer = String::new();
        let mut list_state = ListState::default();

        loop {
            render::draw_selection_ui(
                &mut terminal,
                title,
                instructions,
                entries,
                selected_index,
                &mut list_state,
            )?;

            let event = event::read()
                .with_context(|| format!("Failed to read terminal input for {title} selector"))?;
            match input::handle_event(event, total, &mut selected_index, &mut number_buffer)? {
                input::SelectionAction::Continue => {}
                input::SelectionAction::Select => return Ok(Some(selected_index)),
                input::SelectionAction::Cancel => return Ok(None),
            }
        }
    })();

    let cleanup_result = terminal_guard.restore_with_terminal(&mut terminal);
    cleanup_result?;
    selection_result
}

use terminal::TerminalModeGuard;