collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::*;
use unicode_width::UnicodeWidthChar;

use crate::tui::state::UiState;
use crate::tui::widgets::input::PROMPT_WIDTH;
use crate::tui::widgets::sidebar::{SIDEBAR_MIN_WIDTH, SIDEBAR_WIDTH};
use crate::tui::widgets::{input, output, popup, sidebar, status};

/// Render the entire UI.
pub fn render(frame: &mut Frame, state: &UiState, working_dir: &str) {
    let size = frame.area();

    let show_sidebar = !state.fast_mode && size.width >= SIDEBAR_MIN_WIDTH;

    // Horizontal split: main area (chat+input+status) | [sidebar full height]
    let (main_area, sidebar_area_opt) = if show_sidebar {
        let main_w = size.width.saturating_sub(SIDEBAR_WIDTH);
        let cols = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Length(main_w),
                Constraint::Length(SIDEBAR_WIDTH),
            ])
            .split(size);
        (cols[0], Some(cols[1]))
    } else {
        (size, None)
    };

    // Adaptive input height: 1 separator + visual lines of text, capped at 8.
    let text_w = main_area.width.saturating_sub(PROMPT_WIDTH) as usize;
    // Count visual lines using the same char-level wrap logic as input
    // rendering and cursor calculation.
    let input_line_height: u16 = {
        let mut rows = 1u16;
        let mut col = 0usize;
        for ch in state.input.chars() {
            if ch == '\n' {
                rows += 1;
                col = 0;
            } else {
                let w = ch.width().unwrap_or(1);
                if text_w > 0 && col + w > text_w {
                    rows += 1;
                    col = 0;
                }
                col += w;
            }
        }
        rows
    };
    let input_height = (1 + input_line_height + 1).clamp(3, 10); // sep + text + bottom_pad

    // Vertical layout within main area: content | input (adaptive) | status bar (3)
    let main_layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(10),              // content row
            Constraint::Length(input_height), // input (adaptive)
            Constraint::Length(3),            // status bar (line1 + gap + line2)
        ])
        .split(main_area);

    // Render chat output
    output::render(state, main_layout[0], frame.buffer_mut());

    // Render sidebar (full height)
    if let Some(sa) = sidebar_area_opt {
        sidebar::render(state, sa, frame.buffer_mut(), working_dir);
    }

    input::render(state, main_layout[1], frame.buffer_mut());
    status::render(state, main_layout[2], frame.buffer_mut());

    // Popup overlay — always on top
    popup::render(state, size, frame.buffer_mut());

    // Soul toast — ephemeral, fades out over ~4 seconds
    if let Some((msg, shown_at)) = &state.soul_toast {
        let elapsed = shown_at.elapsed().as_millis();
        // Visible for 4s total: full 2s → dim 1s → muted 1s → gone
        let color = if elapsed < 2000 {
            state.theme.text_dim
        } else if elapsed < 3000 {
            state.theme.text_muted
        } else if elapsed < 4000 {
            state.theme.border
        } else {
            // Expired — skip rendering (tick will clear it)
            return;
        };
        let toast_text = format!("{msg}  ");
        let x = main_area.x + main_area.width.saturating_sub(toast_text.len() as u16 + 2);
        let y = main_layout[0].y + main_layout[0].height.saturating_sub(2);
        let buf = frame.buffer_mut();
        for (i, ch) in toast_text.chars().enumerate() {
            let cell = buf.cell_mut((x + i as u16, y));
            if let Some(c) = cell {
                c.set_char(ch);
                c.set_fg(color);
            }
        }
    }

    // Terminal cursor: compute row/col correctly for multiline input.
    if !state.agent_busy {
        let input_area = main_layout[1];
        let tw = input_area.width.saturating_sub(PROMPT_WIDTH) as usize;
        let text_before = &state.input[..state.cursor.min(state.input.len())];
        let mut col = 0usize;
        let mut row = 0u16;
        for ch in text_before.chars() {
            if ch == '\n' {
                row += 1;
                col = 0;
            } else {
                let w = ch.width().unwrap_or(1);
                if tw > 0 && col + w > tw {
                    row += 1;
                    col = 0;
                }
                col += w;
            }
        }
        let cursor_x = (input_area.x + PROMPT_WIDTH + col as u16)
            .min(input_area.x + input_area.width.saturating_sub(1));
        let cursor_y = input_area.y + 1 + row; // +1 for separator
        // Only set cursor if it falls within the input area — long pastes can push
        // the calculated row past the capped height, which corrupts other widgets.
        if cursor_y < input_area.y + input_area.height {
            frame.set_cursor_position((cursor_x, cursor_y));
        }
    }
}