zeph-tui 0.11.4

Ratatui-based TUI dashboard with real-time metrics for Zeph
Documentation
use ratatui::Frame;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};

use crate::command::{CommandEntry, filter_commands};
use crate::layout::centered_rect;
use crate::theme::Theme;

pub struct CommandPaletteState {
    pub query: String,
    pub cursor: usize,
    pub selected: usize,
    pub filtered: Vec<&'static CommandEntry>,
}

impl CommandPaletteState {
    #[must_use]
    pub fn new() -> Self {
        Self {
            query: String::new(),
            cursor: 0,
            selected: 0,
            filtered: filter_commands(""),
        }
    }

    pub fn push_char(&mut self, c: char) {
        let byte_offset = self
            .query
            .char_indices()
            .nth(self.cursor)
            .map_or(self.query.len(), |(i, _)| i);
        self.query.insert(byte_offset, c);
        self.cursor += 1;
        self.refilter();
    }

    pub fn pop_char(&mut self) {
        if self.cursor > 0 {
            let byte_offset = self
                .query
                .char_indices()
                .nth(self.cursor - 1)
                .map_or(self.query.len(), |(i, _)| i);
            self.query.remove(byte_offset);
            self.cursor -= 1;
            self.refilter();
        }
    }

    pub fn move_up(&mut self) {
        self.selected = self.selected.saturating_sub(1);
    }

    pub fn move_down(&mut self) {
        if !self.filtered.is_empty() {
            self.selected = (self.selected + 1).min(self.filtered.len() - 1);
        }
    }

    #[must_use]
    pub fn selected_entry(&self) -> Option<&'static CommandEntry> {
        self.filtered.get(self.selected).copied()
    }

    fn refilter(&mut self) {
        self.filtered = filter_commands(&self.query);
        if self.filtered.is_empty() {
            self.selected = 0;
        } else {
            self.selected = self.selected.min(self.filtered.len() - 1);
        }
    }
}

impl Default for CommandPaletteState {
    fn default() -> Self {
        Self::new()
    }
}

pub fn render(state: &CommandPaletteState, frame: &mut Frame, area: Rect) {
    let theme = Theme::default();

    #[allow(clippy::cast_possible_truncation)]
    let height = (state.filtered.len() as u16 + 4).clamp(6, 20);
    let popup = centered_rect(60, height, area);

    frame.render_widget(Clear, popup);

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(theme.panel_border)
        .title(" Command Palette ")
        .title_alignment(Alignment::Center);

    frame.render_widget(block, popup);

    let inner = popup.inner(ratatui::layout::Margin {
        horizontal: 1,
        vertical: 1,
    });

    if inner.height < 2 {
        return;
    }

    let query_area = Rect {
        x: inner.x,
        y: inner.y,
        width: inner.width,
        height: 1,
    };

    let query_line = Line::from(vec![
        Span::styled(": ", theme.highlight),
        Span::raw(&state.query),
    ]);
    frame.render_widget(Paragraph::new(query_line), query_area);

    if inner.height < 3 {
        return;
    }

    let list_area = Rect {
        x: inner.x,
        y: inner.y + 2,
        width: inner.width,
        height: inner.height - 2,
    };

    let items: Vec<ListItem> = state
        .filtered
        .iter()
        .enumerate()
        .map(|(i, entry)| {
            let style = if i == state.selected {
                Style::default().bg(theme.highlight.fg.unwrap_or(ratatui::style::Color::Blue))
            } else {
                Style::default()
            };
            let shortcut_str = entry.shortcut.map_or(String::new(), |s| format!(" [{s}]"));
            let shortcut_style = style.patch(Style::default().fg(ratatui::style::Color::DarkGray));
            ListItem::new(Line::from(vec![
                Span::styled(format!("{:<20}", entry.id), style.patch(theme.panel_title)),
                Span::styled(format!("  {}", entry.label), style),
                Span::styled(shortcut_str, shortcut_style),
            ]))
        })
        .collect();

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

    frame.render_stateful_widget(List::new(items), list_area, &mut list_state);
}

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

    #[test]
    fn new_state_has_all_commands() {
        let state = CommandPaletteState::new();
        assert!(state.filtered.len() >= 11);
        assert_eq!(state.selected, 0);
        assert!(state.query.is_empty());
        assert_eq!(state.cursor, 0);
    }

    #[test]
    fn push_char_updates_query_and_filters() {
        let mut state = CommandPaletteState::new();
        state.push_char('s');
        state.push_char('k');
        assert_eq!(state.query, "sk");
        assert_eq!(state.cursor, 2);
        assert!(!state.filtered.is_empty());
        assert_eq!(state.filtered[0].id, "skill:list");
    }

    #[test]
    fn pop_char_removes_last_char() {
        let mut state = CommandPaletteState::new();
        state.push_char('s');
        state.push_char('k');
        state.pop_char();
        assert_eq!(state.query, "s");
        assert_eq!(state.cursor, 1);
    }

    #[test]
    fn pop_char_on_empty_is_noop() {
        let mut state = CommandPaletteState::new();
        state.pop_char();
        assert!(state.query.is_empty());
        assert_eq!(state.cursor, 0);
    }

    #[test]
    fn move_down_increments_selection() {
        let mut state = CommandPaletteState::new();
        assert_eq!(state.selected, 0);
        state.move_down();
        assert_eq!(state.selected, 1);
    }

    #[test]
    fn move_down_clamps_at_last() {
        let mut state = CommandPaletteState::new();
        let last = state.filtered.len() - 1;
        state.selected = last;
        state.move_down();
        assert_eq!(state.selected, last);
    }

    #[test]
    fn move_up_decrements_selection() {
        let mut state = CommandPaletteState::new();
        state.selected = 3;
        state.move_up();
        assert_eq!(state.selected, 2);
    }

    #[test]
    fn move_up_clamps_at_zero() {
        let mut state = CommandPaletteState::new();
        state.selected = 0;
        state.move_up();
        assert_eq!(state.selected, 0);
    }

    #[test]
    fn selected_entry_returns_correct_command() {
        let state = CommandPaletteState::new();
        let entry = state.selected_entry().unwrap();
        assert_eq!(entry.id, "skill:list");
    }

    #[test]
    fn selected_entry_returns_none_when_empty_filter() {
        let mut state = CommandPaletteState::new();
        for c in "xxxxxxxxxx".chars() {
            state.push_char(c);
        }
        assert!(state.selected_entry().is_none());
    }

    #[test]
    fn refilter_clamps_selection_to_new_len() {
        let mut state = CommandPaletteState::new();
        state.selected = 5;
        state.push_char('s');
        state.push_char('k');
        assert!(state.selected < state.filtered.len().max(1));
    }

    #[test]
    fn render_command_palette_snapshot() {
        let state = CommandPaletteState::new();
        let output = render_to_string(80, 24, |frame, area| {
            render(&state, frame, area);
        });
        assert!(output.contains("Command Palette"));
        assert!(output.contains("skill:list"));
        assert!(output.contains("mcp:list"));
    }

    #[test]
    fn render_with_query() {
        let mut state = CommandPaletteState::new();
        state.push_char('v');
        state.push_char('i');
        state.push_char('e');
        state.push_char('w');
        let output = render_to_string(80, 24, |frame, area| {
            render(&state, frame, area);
        });
        assert!(
            output.contains("view:cost")
                || output.contains("view:config")
                || output.contains("view:tools")
        );
    }
}