zc2 0.0.25

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! The editable prompt for the REPL.
//!
//! Phase 3 of the terminal refactor (see `docs/TERMINAL_REFACTOR.md`).
//!
//! Wraps [`tui_textarea::TextArea`] to get a real line editor (cursor motion,
//! word ops, selection) and layers on command history. Enter submits; Alt+Enter
//! inserts a newline (multiline input).

#![allow(dead_code)]

use crossterm::event::KeyEvent;
use ratatui::style::Style;
use tui_textarea::TextArea;

use crate::tui::theme::Theme;

/// Max retained command-history entries; oldest dropped beyond this.
const MAX_HISTORY: usize = 1000;

/// The prompt editor plus its history ring.
pub struct Prompt {
    textarea: TextArea<'static>,
    theme: Theme,
    history: Vec<String>,
    /// Cursor into `history` while browsing; `None` == editing a fresh line.
    hist_pos: Option<usize>,
    /// The in-progress line stashed when the user starts browsing history.
    stash: String,
    /// Mask char for secret entry (e.g. the API key); `None` == plain text.
    mask: Option<char>,
    /// Placeholder shown when empty.
    placeholder: String,
}

const DEFAULT_PLACEHOLDER: &str = "Type a command, or / for the palette…";

impl Prompt {
    pub fn new(theme: &Theme) -> Self {
        let mut p = Prompt {
            textarea: TextArea::default(),
            theme: theme.clone(),
            history: Vec::new(),
            hist_pos: None,
            stash: String::new(),
            mask: None,
            placeholder: DEFAULT_PLACEHOLDER.to_string(),
        };
        p.fresh_textarea();
        p
    }

    /// Build a freshly-styled, empty editor (used on init and after submit).
    fn fresh_textarea(&mut self) {
        let mut ta = TextArea::default();
        Self::apply_style(&self.theme, &mut ta);
        self.apply_overrides(&mut ta);
        self.textarea = ta;
    }

    fn apply_style(theme: &Theme, textarea: &mut TextArea<'static>) {
        // No block here: the app frames the prompt with full-width rules and a
        // `❯` gutter (Claude-Code style), so the editor itself is borderless.
        textarea.set_cursor_line_style(Style::default());
        textarea.set_style(Style::default().fg(theme.fg));
        textarea.set_cursor_style(Style::default().bg(theme.accent).fg(theme.bg));
    }

    /// Apply per-prompt overrides (placeholder, mask) that survive resets.
    fn apply_overrides(&self, textarea: &mut TextArea<'static>) {
        textarea.set_placeholder_text(self.placeholder.clone());
        match self.mask {
            Some(c) => textarea.set_mask_char(c),
            None => textarea.clear_mask_char(),
        }
    }

    /// Mask input with `c` (secret entry, e.g. the API key).
    pub fn set_mask(&mut self, c: char) {
        self.mask = Some(c);
        self.textarea.set_mask_char(c);
    }

    /// Stop masking input.
    pub fn clear_mask(&mut self) {
        self.mask = None;
        self.textarea.clear_mask_char();
    }

    /// Set the placeholder shown when the prompt is empty.
    pub fn set_placeholder(&mut self, s: &str) {
        self.placeholder = s.to_string();
        self.textarea.set_placeholder_text(s.to_string());
    }

    /// Re-apply a (possibly hot-swapped) theme, preserving the current text.
    pub fn retheme(&mut self, theme: &Theme) {
        self.theme = theme.clone();
        let t = self.theme.clone();
        Self::apply_style(&t, &mut self.textarea);
        let mask = self.mask;
        match mask {
            Some(c) => self.textarea.set_mask_char(c),
            None => self.textarea.clear_mask_char(),
        }
    }

    /// The current input as a single string (lines joined with `\n`).
    pub fn text(&self) -> String {
        self.textarea.lines().join("\n")
    }

    pub fn is_empty(&self) -> bool {
        self.textarea.lines().iter().all(|l| l.is_empty())
    }

    /// Number of visual lines (for laying out the input box height).
    pub fn line_count(&self) -> usize {
        self.textarea.lines().len().max(1)
    }

    /// Forward a key event to the editor; editing cancels history browsing.
    pub fn input(&mut self, key: KeyEvent) {
        self.textarea.input(key);
        self.hist_pos = None;
    }

    /// Insert a literal newline (Alt+Enter).
    pub fn newline(&mut self) {
        self.textarea.insert_str("\n");
    }

    /// Clear the editor WITHOUT recording the text in history (use for secrets
    /// like the API key, and for discarding input).
    pub fn clear(&mut self) {
        self.hist_pos = None;
        self.stash.clear();
        self.fresh_textarea();
    }

    /// Replace the whole buffer with `s`, keeping styling.
    fn set_text(&mut self, s: &str) {
        let lines: Vec<String> = s.split('\n').map(|l| l.to_string()).collect();
        let mut ta = TextArea::from(lines);
        Self::apply_style(&self.theme, &mut ta);
        self.apply_overrides(&mut ta);
        self.textarea = ta;
    }

    /// Push an entry into the command history, capped at `MAX_HISTORY`
    /// (oldest dropped, FIFO) so a long-lived session can't grow unbounded.
    fn push_history(&mut self, entry: String) {
        self.history.push(entry);
        if self.history.len() > MAX_HISTORY {
            self.history.remove(0);
        }
    }

    /// Take the current input, push it to history, and clear the editor.
    pub fn submit(&mut self) -> String {
        let trimmed = self.text().trim().to_string();
        if !trimmed.is_empty() && self.history.last().map(|h| h != &trimmed).unwrap_or(true) {
            self.push_history(trimmed.clone());
        }
        self.hist_pos = None;
        self.stash.clear();
        self.fresh_textarea();
        trimmed
    }

    /// Recall the previous history entry (↑).
    pub fn history_prev(&mut self) {
        if self.history.is_empty() {
            return;
        }
        let next = match self.hist_pos {
            None => {
                self.stash = self.text();
                self.history.len() - 1
            }
            Some(0) => 0,
            Some(p) => p - 1,
        };
        self.hist_pos = Some(next);
        let entry = self.history[next].clone();
        self.set_text(&entry);
    }

    /// Move forward in history (↓), eventually restoring the stashed line.
    pub fn history_next(&mut self) {
        match self.hist_pos {
            None => {}
            Some(p) if p + 1 < self.history.len() => {
                self.hist_pos = Some(p + 1);
                let entry = self.history[p + 1].clone();
                self.set_text(&entry);
            }
            Some(_) => {
                self.hist_pos = None;
                let stash = self.stash.clone();
                self.set_text(&stash);
            }
        }
    }

    /// The widget to render.
    pub fn widget(&self) -> impl ratatui::widgets::Widget + '_ {
        self.textarea.widget()
    }
}

#[cfg(test)]
mod history_cap_tests {
    use super::*;
    use crate::tui::theme::Theme;

    #[test]
    fn history_is_capped_fifo() {
        let theme = Theme::dark();
        let mut p = Prompt::new(&theme);
        for i in 0..(MAX_HISTORY + 50) {
            p.push_history(format!("cmd{}", i));
        }
        assert_eq!(
            p.history.len(),
            MAX_HISTORY,
            "history must be capped at MAX_HISTORY"
        );
        // oldest dropped, newest retained
        assert!(!p.history.contains(&"cmd0".to_string()));
        assert!(p.history.contains(&format!("cmd{}", MAX_HISTORY + 49)));
    }
}