collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use ratatui::prelude::*;
use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph};
use unicode_width::UnicodeWidthChar;

use crate::tui::state::{AcKind, MentionAc, UiState};
use crate::tui::theme::Theme;

const MAX_VISIBLE: usize = 10;

/// Width of the prompt prefix ("> " or "~ ").
pub const PROMPT_WIDTH: u16 = 2;

pub fn render(state: &UiState, area: Rect, buf: &mut Buffer) {
    let theme = &state.theme;

    // Fill input area background (theme-aware) using Block widget for efficiency.
    ratatui::widgets::Block::default()
        .style(Style::default().bg(theme.bg))
        .render(area, buf);

    // Row 0: thin separator line
    if area.height > 0 {
        let sep_area = Rect { height: 1, ..area };
        let sep = "".repeat(area.width as usize);
        // Use border_busy color when the agent is actively processing.
        let sep_color = if state.agent_busy {
            theme.border_busy
        } else {
            theme.border
        };
        Paragraph::new(Span::styled(sep, Style::default().fg(sep_color).dim()))
            .render(sep_area, buf);

        // Approve mode badge floating above the separator line (row y-1)
        if area.y > 0 {
            let approve_label = state.approve_mode.to_uppercase();
            let badge_text = format!(" {} ", approve_label);
            let badge_style = Style::default().fg(theme.text_muted).dim();
            for (i, ch) in badge_text.chars().enumerate() {
                let x = area.x + i as u16;
                if x < area.x + area.width {
                    let cell = &mut buf[(x, area.y - 1)];
                    cell.set_char(ch);
                    cell.set_style(badge_style);
                }
            }
        }
    }

    // Row 1+: prompt symbol + input text (no blank row between separator and text)
    if area.height > 1 {
        let input_y = area.y + 1;

        let (prompt_span, prompt_color) = if state.agent_busy {
            (
                Span::styled("~ ", Style::default().fg(theme.status_busy_bg).bold()),
                theme.status_busy_bg,
            )
        } else if state.shell_mode {
            (
                Span::styled("! ", Style::default().fg(theme.warning).bold()),
                theme.warning,
            )
        } else {
            (
                Span::styled("> ", Style::default().fg(theme.accent).bold()),
                theme.accent,
            )
        };
        let _ = prompt_color; // used via prompt_span

        let display_text =
            if state.input.is_empty() && state.pending_images.is_empty() && !state.agent_busy {
                if state.shell_mode {
                    "Shell command…".to_string()
                } else {
                    "Message…".to_string()
                }
            } else {
                state.input_display()
            };

        let text_style = if state.input.is_empty() && !state.agent_busy {
            Style::default().fg(theme.text_muted)
        } else {
            Style::default().fg(theme.text)
        };

        // Prompt symbol
        let prompt_area = Rect::new(area.x, input_y, PROMPT_WIDTH, 1);
        Paragraph::new(prompt_span).render(prompt_area, buf);

        // Input text (after prompt) — multiline, grows with content.
        let text_x = area.x + PROMPT_WIDTH;
        let text_w = area.width.saturating_sub(PROMPT_WIDTH);
        let text_h = area.height.saturating_sub(1).max(1); // rows below separator
        if text_w > 0 {
            let text_area = Rect::new(text_x, input_y, text_w, text_h);
            let tw = text_w as usize;

            // Manual char-level wrapping — identical to cursor position
            // calculation in render.rs so the two never diverge.
            let mut visual_lines: Vec<Vec<Span>> = vec![vec![]];
            let mut col = 0usize;
            for ch in display_text.chars() {
                if ch == '\n' {
                    visual_lines.push(vec![]);
                    col = 0;
                } else {
                    let w = ch.width().unwrap_or(1);
                    if tw > 0 && col + w > tw {
                        visual_lines.push(vec![]);
                        col = 0;
                    }
                    col += w;
                    // Append char to current visual line's text buffer
                    let last = visual_lines
                        .last_mut()
                        .expect("initialized with one element");
                    if let Some(span) = last.last_mut() {
                        let mut s = span.content.to_string();
                        s.push(ch);
                        *span = Span::styled(s, text_style);
                    } else {
                        last.push(Span::styled(String::from(ch), text_style));
                    }
                }
            }

            // Append hint to the last visual line if present
            if let Some(ref hint) = state.input_hint {
                let last = visual_lines
                    .last_mut()
                    .expect("initialized with one element");
                last.push(Span::styled(
                    hint.clone(),
                    Style::default().fg(theme.text_muted).dim(),
                ));
            }

            let lines: Vec<Line> = visual_lines.into_iter().map(Line::from).collect();
            Paragraph::new(lines).render(text_area, buf);
        }
    }

    // Render autocomplete popups (mention takes priority)
    if let Some(ref ac) = state.mention_ac {
        render_ac_popup(ac, area, buf, theme, theme.accent);
    } else if let Some(ref ac) = state.command_ac {
        render_ac_popup(ac, area, buf, theme, theme.success);
    }
}

/// Render a scrollable autocomplete popup above the input box.
fn render_ac_popup(ac: &MentionAc, area: Rect, buf: &mut Buffer, theme: &Theme, accent: Color) {
    let count = ac.candidates.len();
    let visible = count.min(MAX_VISIBLE);
    let height = visible as u16 + 2;

    if area.y < height {
        return;
    }

    let popup = Rect {
        x: area.x,
        y: area.y.saturating_sub(height),
        width: area.width.min(64),
        height,
    };

    let scroll_start = if ac.selected >= MAX_VISIBLE {
        ac.selected - MAX_VISIBLE + 1
    } else {
        0
    };
    let end = (scroll_start + visible).min(count);
    let selected_in_window = ac.selected.saturating_sub(scroll_start);

    let title = if count > MAX_VISIBLE {
        format!(
            " {} ({}/{}) ↑↓ ",
            if ac.prefix.is_empty() {
                ">"
            } else {
                &ac.prefix
            },
            ac.selected + 1,
            count
        )
    } else {
        format!(
            " {} ",
            if ac.prefix.is_empty() {
                ">"
            } else {
                &ac.prefix
            }
        )
    };

    Clear.render(popup, buf);

    let items: Vec<ListItem> = ac.candidates[scroll_start..end]
        .iter()
        .enumerate()
        .map(|(i, c)| {
            let icon = match c.kind {
                AcKind::Agent => " ",
                AcKind::Dir => " ",
                AcKind::File => " ",
                AcKind::Command => " ",
                AcKind::Skill => "󱍤 ",
            };
            let text = format!("{icon}{}", c.label);
            if i == selected_in_window {
                ListItem::new(text).style(
                    Style::default()
                        .fg(theme.status_fg)
                        .bg(accent)
                        .add_modifier(Modifier::BOLD),
                )
            } else {
                ListItem::new(text).style(Style::default().fg(theme.text))
            }
        })
        .collect();

    let mut list_state = ListState::default();
    list_state.select(Some(selected_in_window));

    let list = List::new(items)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_type(BorderType::Rounded)
                .border_style(Style::default().fg(accent))
                .title(title),
        )
        .style(Style::default().bg(theme.bg_surface));

    ratatui::widgets::StatefulWidget::render(list, popup, buf, &mut list_state);
}