zc2 0.0.25

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! Slash-command palette: fuzzy search over the command registry.
//!
//! Phase 4 of the terminal refactor (see `docs/TERMINAL_REFACTOR.md`).
//!
//! Triggered by typing `/` at an empty prompt. Filters the [`CommandRegistry`]
//! with a fuzzy matcher, shows arg hints from each [`CommandSpec`], and supports
//! Up/Down selection + Enter/Tab to complete.

#![allow(dead_code)]

use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState};
use ratatui::Frame;

use crate::command::{CommandRegistry, CommandSpec};
use crate::tui::theme::Theme;

/// State for the command palette overlay.
pub struct Palette {
    matcher: SkimMatcherV2,
    /// Names of the currently-matching commands, best first.
    matches: Vec<&'static str>,
    selected: usize,
}

impl Palette {
    pub fn new() -> Self {
        Palette {
            matcher: SkimMatcherV2::default(),
            matches: Vec::new(),
            selected: 0,
        }
    }

    /// Recompute matches for `query` (the prompt text *after* the leading `/`).
    pub fn update(&mut self, registry: &CommandRegistry, query: &str) {
        let q = query.trim();
        let mut scored: Vec<(i64, &'static str)> = registry
            .all()
            .iter()
            .filter_map(|spec| {
                if q.is_empty() {
                    Some((0, spec.name))
                } else {
                    self.matcher
                        .fuzzy_match(spec.name, q)
                        .or_else(|| {
                            spec.aliases
                                .iter()
                                .filter_map(|a| self.matcher.fuzzy_match(a, q))
                                .max()
                        })
                        .map(|score| (score, spec.name))
                }
            })
            .collect();
        // Higher score first; stable by name for ties.
        scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
        self.matches = scored.into_iter().map(|(_, n)| n).collect();
        if self.selected >= self.matches.len() {
            self.selected = self.matches.len().saturating_sub(1);
        }
    }

    pub fn is_empty(&self) -> bool {
        self.matches.is_empty()
    }

    pub fn select_next(&mut self) {
        if !self.matches.is_empty() {
            self.selected = (self.selected + 1) % self.matches.len();
        }
    }

    pub fn select_prev(&mut self) {
        if !self.matches.is_empty() {
            self.selected = (self.selected + self.matches.len() - 1) % self.matches.len();
        }
    }

    /// The command name currently highlighted, if any.
    pub fn selected_name(&self) -> Option<&'static str> {
        self.matches.get(self.selected).copied()
    }

    pub fn reset(&mut self) {
        self.matches.clear();
        self.selected = 0;
    }

    /// Render the palette as a bordered overlay anchored above `anchor`.
    pub fn render(
        &self,
        frame: &mut Frame,
        anchor: Rect,
        registry: &CommandRegistry,
        theme: &Theme,
    ) {
        let max_rows = 8usize;
        let shown = self.matches.len().min(max_rows);
        let height = (shown as u16) + 2; // borders
        let width = anchor.width.min(64);
        let x = anchor.x;
        let y = anchor.y.saturating_sub(height);
        let area = Rect::new(x, y, width, height);

        let items: Vec<ListItem> = self
            .matches
            .iter()
            .take(max_rows)
            .map(|name| {
                let spec = registry.resolve(name);
                let usage = spec
                    .map(CommandSpec::usage)
                    .unwrap_or_else(|| name.to_string());
                let summary = spec.map(|s| s.summary).unwrap_or("");
                ListItem::new(Line::from(vec![
                    Span::styled(format!("/{:<22}", usage), Style::default().fg(theme.accent)),
                    Span::styled(summary.to_string(), Style::default().fg(theme.muted)),
                ]))
            })
            .collect();

        let block = Block::default()
            .borders(Borders::ALL)
            .border_type(BorderType::Rounded)
            .border_style(Style::default().fg(theme.warning))
            .title(Span::styled(
                " commands ",
                Style::default()
                    .fg(theme.warning)
                    .add_modifier(Modifier::BOLD),
            ))
            .style(Style::default().bg(theme.overlay_bg));

        let list = List::new(items).block(block).highlight_style(
            Style::default()
                .bg(theme.highlight)
                .add_modifier(Modifier::BOLD),
        );

        let mut state = ListState::default();
        if !self.matches.is_empty() {
            state.select(Some(self.selected.min(shown.saturating_sub(1))));
        }

        frame.render_widget(Clear, area);
        frame.render_stateful_widget(list, area, &mut state);
    }
}

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