qqq-cli 0.12.0

Local-first task queue for coding agent sessions
use super::{
    render::{self, PopupKind, PopupRow},
    tag_input,
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::path::PathBuf;
use unicode_segmentation::UnicodeSegmentation;

pub(super) const DEFAULT_PROMPT: &str = "/goal Use the `qqq cli --wait`, to look for next task, and work on that. Repeat this forever. Please don't wake up repeatedly to poll the status when running waiter.";

pub(super) enum Action {
    Create(String),
    Cancel,
}

pub(super) struct View {
    root: PathBuf,
    value: String,
    cursor: tag_input::Cursor,
    error: String,
}

impl View {
    pub fn new(root: PathBuf, prompt: Option<String>) -> Self {
        let value = prompt.unwrap_or_else(|| DEFAULT_PROMPT.into());
        let cursor = tag_input::Cursor::at_end(&value);
        Self {
            root,
            value,
            cursor,
            error: String::new(),
        }
    }

    pub fn paste(&mut self, text: &str) {
        self.cursor
            .insert(&mut self.value, &text.replace("\r\n", "\n"));
        self.error.clear();
    }

    pub fn key(&mut self, key: KeyEvent, width: usize) -> Option<Action> {
        if key.code == KeyCode::Esc
            || (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'))
        {
            return Some(Action::Cancel);
        }
        if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('h') {
            if self.value.trim().is_empty() {
                self.error = "Enter a prompt".into();
            } else {
                return Some(Action::Create(self.value.clone()));
            }
        } else if matches!(key.code, KeyCode::Up | KeyCode::Down)
            && !key
                .modifiers
                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
        {
            let layout = self.layout(width);
            self.cursor
                .visual_vertical(&self.value, &layout, key.code == KeyCode::Up);
        } else {
            self.cursor.edit(&mut self.value, &mut self.error, key);
        }
        None
    }

    fn layout(&self, width: usize) -> render::Layout {
        let fragments: Vec<_> = self.value.graphemes(true).map(str::to_owned).collect();
        // Reserve one cell for the caret.
        render::Layout::new(&fragments, &[], width.saturating_sub(1))
    }

    pub fn popup(
        &self,
        area: ratatui::layout::Rect,
    ) -> (super::dashboard::PopupLayout, Vec<PopupRow>) {
        let content = super::dashboard::popup_layout(area, usize::MAX).content;
        let rows = self.rows(usize::from(content.width), usize::from(content.height));
        let popup = super::dashboard::popup_layout(area, rows.len()).enlarged(area);
        let mut rows = self.rows(
            usize::from(popup.content.width),
            usize::from(popup.content.height),
        );
        let padding = usize::from(popup.content.height).saturating_sub(rows.len());
        let footer = rows.len().saturating_sub(1);
        rows.splice(
            footer..footer,
            std::iter::repeat_with(|| PopupRow::new("", PopupKind::Input)).take(padding),
        );
        (popup, rows)
    }

    pub fn rows(&self, width: usize, height: usize) -> Vec<PopupRow> {
        let heading = usize::from(height >= 3);
        let guidance = usize::from(height >= if self.error.is_empty() { 4 } else { 5 });
        let footer = usize::from(height >= 2);
        let body = height.saturating_sub(heading + guidance + footer);
        let errors: Vec<_> = super::wrap_modal(&self.error, width)
            .into_iter()
            .filter(|_| !self.error.is_empty())
            .take(body.saturating_sub(1).min(3))
            .collect();
        let input_height = body.saturating_sub(errors.len());
        let layout = self.layout(width);
        let caret = self.value[..self.cursor.byte()].graphemes(true).count();
        let (active, column) = layout.positions[caret];
        let start = active
            .saturating_sub(input_height / 2)
            .min(layout.rows.len().saturating_sub(input_height));
        let mut rows = Vec::new();
        if heading > 0 {
            rows.push(PopupRow::new("Create Herdr agent", PopupKind::Heading));
        }
        if guidance > 0 {
            rows.push(PopupRow::new(
                format!("Directory: {}", self.root.display()),
                PopupKind::Hint,
            ));
        }
        rows.extend(
            layout
                .rows
                .iter()
                .enumerate()
                .skip(start)
                .take(input_height)
                .map(|(index, text)| {
                    let mut row = PopupRow::new(text, PopupKind::Input);
                    if index == active {
                        row.cursor = Some(column.min(width.saturating_sub(1)));
                    }
                    row
                }),
        );
        rows.extend(
            errors
                .into_iter()
                .map(|text| PopupRow::new(text, PopupKind::Error)),
        );
        if footer > 0 {
            let hint = if width >= 37 {
                "Enter line  Ctrl-H create  Esc cancel"
            } else if width >= 21 {
                "Enter  Ctrl-H  Esc"
            } else {
                "↵ ^H Esc"
            };
            rows.push(PopupRow::new(hint, PopupKind::Hint));
        }
        rows
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn popup_is_half_larger_and_keeps_footer_and_cursor_inside_small_terminals() {
        use ratatui::{Terminal, backend::TestBackend, layout::Rect};
        let view = View::new(PathBuf::from("/project"), None);
        for (width, height, expected_width, expected_height) in [
            (120, 40, 72, 14),
            (72, 24, 68, 14),
            (40, 12, 40, 12),
            (12, 8, 12, 8),
        ] {
            for color in [true, false] {
                let area = Rect::new(0, 0, width, height);
                let (popup, rows) = view.popup(area);
                assert_eq!(popup.outer.width, expected_width);
                assert_eq!(popup.outer.height, expected_height);
                assert_eq!(popup.outer.x, (width - expected_width) / 2);
                assert_eq!(popup.outer.y, (height - expected_height) / 2);
                assert_eq!(rows.len(), usize::from(popup.content.height));
                assert_eq!(rows.last().unwrap().kind, PopupKind::Hint);
                let (row, column) = rows
                    .iter()
                    .enumerate()
                    .find_map(|(row, value)| value.cursor.map(|column| (row, column)))
                    .unwrap();
                let expected_cursor = ratatui::layout::Position::new(
                    popup.content.x + column as u16,
                    popup.content.y + row as u16,
                );
                let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
                terminal
                    .draw(|frame| {
                        super::super::dashboard::popup_at(frame, &rows, color, true, popup)
                    })
                    .unwrap();
                assert_eq!(terminal.get_cursor_position().unwrap(), expected_cursor);
            }
        }
    }

    #[test]
    fn ctrl_h_confirms_prompt_and_ctrl_s_leaves_it_open() {
        let prompt = "Run 'literal $(text)'\nSecond line";
        let mut view = View::new(PathBuf::from("/project"), Some(prompt.into()));
        assert!(
            view.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), 64)
                .is_none()
        );
        assert_eq!(view.value, prompt);
        assert!(matches!(
            view.key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), 64),
            Some(Action::Create(value)) if value == prompt
        ));

        let mut empty = View::new(PathBuf::from("/project"), Some(" \n ".into()));
        assert!(
            empty
                .key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), 64)
                .is_none()
        );
        assert_eq!(empty.error, "Enter a prompt");
    }

    #[test]
    fn down_reaches_wrapped_row_starting_with_wide_grapheme() {
        let mut view = View::new(PathBuf::from("/project"), None);
        view.value = format!("{}界bbb", "a".repeat(45));
        view.cursor = tag_input::Cursor::at_end(&view.value);
        view.key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), 46);
        view.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), 46);
        let layout = view.layout(46);
        let caret = view.value[..view.cursor.byte()].graphemes(true).count();
        assert_eq!(layout.positions[caret].0, 1);
        assert!(view.value.is_char_boundary(view.cursor.byte()));
    }

    #[test]
    fn arrows_move_within_wrapped_prompt_before_editing() {
        let mut view = View::new(PathBuf::from("/project"), None);
        let end = view.cursor.byte();
        view.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), 36);
        assert!(view.cursor.byte() < end);
        let insertion = view.cursor.byte();
        view.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), 36);
        assert_eq!(view.cursor.byte(), end);
        view.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), 36);
        assert_eq!(view.cursor.byte(), insertion);
        view.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE), 36);
        let mut expected = DEFAULT_PROMPT.to_owned();
        expected.insert(insertion, 'X');
        assert_eq!(view.value, expected);
    }

    #[test]
    fn default_prompt_is_readable_without_horizontal_scrolling() {
        let view = View::new(PathBuf::from("/project"), None);
        for (width, height) in [(64, 10), (36, 12)] {
            let rows = view.rows(width, height);
            let text: String = rows
                .iter()
                .filter(|row| row.kind == PopupKind::Input)
                .map(|row| row.text.as_str())
                .collect();
            assert_eq!(text, DEFAULT_PROMPT);
            assert!(rows.iter().any(|row| row.cursor.is_some()));
            assert!(rows.len() <= height);
            assert!(rows.last().unwrap().text.contains("Ctrl-H"));
        }
        assert_eq!(view.rows(20, 4).last().unwrap().text, "↵ ^H Esc");
    }
}