ricecoder_tui/
prompt.rs

1//! Prompt widget for displaying the command prompt
2//!
3//! This module provides the `PromptWidget` for displaying a beautiful, styled command prompt
4//! with context information. The prompt displays:
5//! - Git branch information
6//! - Project name
7//! - Current application mode (Chat, Command, Diff, Help)
8//! - Active AI provider and model
9//!
10//! # Features
11//!
12//! - **Context indicators**: Display git branch, project name, mode, and provider
13//! - **Multi-line input**: Support for multi-line command input with text wrapping
14//! - **Input history**: Navigate through previous commands with up/down arrows
15//! - **Customizable styling**: Configure colors and appearance via `PromptConfig`
16//! - **Cursor positioning**: Full cursor control and text editing
17//!
18//! # Examples
19//!
20//! Creating a prompt widget with context:
21//!
22//! ```ignore
23//! use ricecoder_tui::{PromptWidget, ContextIndicators, AppMode};
24//!
25//! let mut context = ContextIndicators::new();
26//! context.git_branch = Some("main".to_string());
27//! context.project_name = Some("ricecoder".to_string());
28//! context.mode = AppMode::Chat;
29//! context.provider = Some("OpenAI".to_string());
30//! context.model = Some("gpt-4".to_string());
31//!
32//! let mut prompt = PromptWidget::new(context);
33//! ```
34//!
35//! Customizing the prompt appearance:
36//!
37//! ```ignore
38//! use ricecoder_tui::PromptConfig;
39//!
40//! let config = PromptConfig {
41//!     prefix: "❯ ".to_string(),
42//!     show_git_branch: true,
43//!     show_mode: true,
44//!     ..Default::default()
45//! };
46//! ```
47
48use crate::app::AppMode;
49use crate::style::Color;
50
51/// Context indicators for the prompt
52#[derive(Debug, Clone)]
53pub struct ContextIndicators {
54    /// Git branch name
55    pub git_branch: Option<String>,
56    /// Project name
57    pub project_name: Option<String>,
58    /// Current mode
59    pub mode: AppMode,
60    /// AI provider name
61    pub provider: Option<String>,
62    /// AI model name
63    pub model: Option<String>,
64}
65
66impl ContextIndicators {
67    /// Create new context indicators
68    pub fn new() -> Self {
69        Self {
70            git_branch: None,
71            project_name: None,
72            mode: AppMode::Chat,
73            provider: None,
74            model: None,
75        }
76    }
77
78    /// Set git branch
79    pub fn with_git_branch(mut self, branch: impl Into<String>) -> Self {
80        self.git_branch = Some(branch.into());
81        self
82    }
83
84    /// Set project name
85    pub fn with_project_name(mut self, name: impl Into<String>) -> Self {
86        self.project_name = Some(name.into());
87        self
88    }
89
90    /// Set provider and model
91    pub fn with_provider(mut self, provider: impl Into<String>, model: impl Into<String>) -> Self {
92        self.provider = Some(provider.into());
93        self.model = Some(model.into());
94        self
95    }
96
97    /// Format context as string
98    pub fn format(&self) -> String {
99        let mut parts = Vec::new();
100
101        if let Some(branch) = &self.git_branch {
102            parts.push(format!("({})", branch));
103        }
104
105        if let Some(project) = &self.project_name {
106            parts.push(project.clone());
107        }
108
109        let mode_str = match self.mode {
110            AppMode::Chat => "💬",
111            AppMode::Command => "⚙️",
112            AppMode::Diff => "📝",
113            AppMode::Help => "❓",
114        };
115        parts.push(mode_str.to_string());
116
117        if let (Some(provider), Some(model)) = (&self.provider, &self.model) {
118            parts.push(format!("[{}/{}]", provider, model));
119        }
120
121        parts.join(" ")
122    }
123}
124
125impl Default for ContextIndicators {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131/// Prompt configuration
132#[derive(Debug, Clone)]
133pub struct PromptConfig {
134    /// Prompt prefix
135    pub prefix: String,
136    /// Prompt suffix
137    pub suffix: String,
138    /// Foreground color
139    pub fg_color: Color,
140    /// Background color
141    pub bg_color: Option<Color>,
142    /// Show context
143    pub show_context: bool,
144}
145
146impl PromptConfig {
147    /// Create new prompt config
148    pub fn new() -> Self {
149        Self {
150            prefix: "❯".to_string(),
151            suffix: " ".to_string(),
152            fg_color: Color::new(0, 122, 255),
153            bg_color: None,
154            show_context: true,
155        }
156    }
157
158    /// Set prefix
159    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
160        self.prefix = prefix.into();
161        self
162    }
163
164    /// Set suffix
165    pub fn with_suffix(mut self, suffix: impl Into<String>) -> Self {
166        self.suffix = suffix.into();
167        self
168    }
169
170    /// Set foreground color
171    pub fn with_fg_color(mut self, color: Color) -> Self {
172        self.fg_color = color;
173        self
174    }
175
176    /// Set background color
177    pub fn with_bg_color(mut self, color: Color) -> Self {
178        self.bg_color = Some(color);
179        self
180    }
181
182    /// Set show context
183    pub fn with_show_context(mut self, show: bool) -> Self {
184        self.show_context = show;
185        self
186    }
187}
188
189impl Default for PromptConfig {
190    fn default() -> Self {
191        Self::new()
192    }
193}
194
195/// Prompt widget
196pub struct PromptWidget {
197    /// Input text
198    pub input: String,
199    /// Cursor position
200    pub cursor: usize,
201    /// Context indicators
202    pub context: ContextIndicators,
203    /// Prompt configuration
204    pub config: PromptConfig,
205    /// Input history
206    pub history: Vec<String>,
207    /// History index
208    pub history_index: Option<usize>,
209}
210
211impl PromptWidget {
212    /// Create a new prompt widget
213    pub fn new() -> Self {
214        Self {
215            input: String::new(),
216            cursor: 0,
217            context: ContextIndicators::new(),
218            config: PromptConfig::new(),
219            history: Vec::new(),
220            history_index: None,
221        }
222    }
223
224    /// Insert character at cursor
225    pub fn insert_char(&mut self, ch: char) {
226        self.input.insert(self.cursor, ch);
227        self.cursor += 1;
228    }
229
230    /// Delete character before cursor
231    pub fn backspace(&mut self) {
232        if self.cursor > 0 {
233            self.input.remove(self.cursor - 1);
234            self.cursor -= 1;
235        }
236    }
237
238    /// Delete character at cursor
239    pub fn delete(&mut self) {
240        if self.cursor < self.input.len() {
241            self.input.remove(self.cursor);
242        }
243    }
244
245    /// Move cursor left
246    pub fn move_left(&mut self) {
247        if self.cursor > 0 {
248            self.cursor -= 1;
249        }
250    }
251
252    /// Move cursor right
253    pub fn move_right(&mut self) {
254        if self.cursor < self.input.len() {
255            self.cursor += 1;
256        }
257    }
258
259    /// Move cursor to start
260    pub fn move_start(&mut self) {
261        self.cursor = 0;
262    }
263
264    /// Move cursor to end
265    pub fn move_end(&mut self) {
266        self.cursor = self.input.len();
267    }
268
269    /// Submit input
270    pub fn submit(&mut self) -> String {
271        let input = self.input.clone();
272        self.history.push(input.clone());
273        self.input.clear();
274        self.cursor = 0;
275        self.history_index = None;
276        input
277    }
278
279    /// Navigate history up
280    pub fn history_up(&mut self) {
281        if self.history.is_empty() {
282            return;
283        }
284
285        match self.history_index {
286            None => {
287                self.history_index = Some(self.history.len() - 1);
288                self.input = self.history[self.history.len() - 1].clone();
289            }
290            Some(idx) if idx > 0 => {
291                self.history_index = Some(idx - 1);
292                self.input = self.history[idx - 1].clone();
293            }
294            _ => {}
295        }
296
297        self.cursor = self.input.len();
298    }
299
300    /// Navigate history down
301    pub fn history_down(&mut self) {
302        match self.history_index {
303            Some(idx) if idx < self.history.len() - 1 => {
304                self.history_index = Some(idx + 1);
305                self.input = self.history[idx + 1].clone();
306                self.cursor = self.input.len();
307            }
308            Some(_) => {
309                self.history_index = None;
310                self.input.clear();
311                self.cursor = 0;
312            }
313            None => {}
314        }
315    }
316
317    /// Format the prompt line
318    pub fn format_prompt(&self) -> String {
319        let mut prompt = String::new();
320
321        if self.config.show_context {
322            prompt.push_str(&self.context.format());
323            prompt.push(' ');
324        }
325
326        prompt.push_str(&self.config.prefix);
327        prompt.push_str(&self.config.suffix);
328
329        prompt
330    }
331
332    /// Get the full display line
333    pub fn display_line(&self) -> String {
334        format!("{}{}", self.format_prompt(), self.input)
335    }
336}
337
338impl Default for PromptWidget {
339    fn default() -> Self {
340        Self::new()
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_context_indicators() {
350        let context = ContextIndicators::new()
351            .with_git_branch("main")
352            .with_project_name("ricecoder")
353            .with_provider("openai", "gpt-4");
354
355        let formatted = context.format();
356        assert!(formatted.contains("main"));
357        assert!(formatted.contains("ricecoder"));
358        assert!(formatted.contains("openai"));
359    }
360
361    #[test]
362    fn test_prompt_config() {
363        let config = PromptConfig::new().with_prefix("$").with_suffix(" ");
364
365        assert_eq!(config.prefix, "$");
366        assert_eq!(config.suffix, " ");
367    }
368
369    #[test]
370    fn test_prompt_widget_creation() {
371        let widget = PromptWidget::new();
372        assert!(widget.input.is_empty());
373        assert_eq!(widget.cursor, 0);
374    }
375
376    #[test]
377    fn test_prompt_widget_input() {
378        let mut widget = PromptWidget::new();
379        widget.insert_char('h');
380        widget.insert_char('i');
381
382        assert_eq!(widget.input, "hi");
383        assert_eq!(widget.cursor, 2);
384    }
385
386    #[test]
387    fn test_prompt_widget_backspace() {
388        let mut widget = PromptWidget::new();
389        widget.input = "hello".to_string();
390        widget.cursor = 5;
391
392        widget.backspace();
393        assert_eq!(widget.input, "hell");
394        assert_eq!(widget.cursor, 4);
395    }
396
397    #[test]
398    fn test_prompt_widget_cursor_movement() {
399        let mut widget = PromptWidget::new();
400        widget.input = "hello".to_string();
401        widget.cursor = 2;
402
403        widget.move_left();
404        assert_eq!(widget.cursor, 1);
405
406        widget.move_right();
407        assert_eq!(widget.cursor, 2);
408
409        widget.move_start();
410        assert_eq!(widget.cursor, 0);
411
412        widget.move_end();
413        assert_eq!(widget.cursor, 5);
414    }
415
416    #[test]
417    fn test_prompt_widget_submit() {
418        let mut widget = PromptWidget::new();
419        widget.input = "test command".to_string();
420
421        let submitted = widget.submit();
422        assert_eq!(submitted, "test command");
423        assert!(widget.input.is_empty());
424        assert_eq!(widget.history.len(), 1);
425    }
426
427    #[test]
428    fn test_prompt_widget_history() {
429        let mut widget = PromptWidget::new();
430
431        widget.input = "first".to_string();
432        widget.submit();
433
434        widget.input = "second".to_string();
435        widget.submit();
436
437        widget.history_up();
438        assert_eq!(widget.input, "second");
439
440        widget.history_up();
441        assert_eq!(widget.input, "first");
442
443        widget.history_down();
444        assert_eq!(widget.input, "second");
445
446        widget.history_down();
447        assert!(widget.input.is_empty());
448    }
449
450    #[test]
451    fn test_prompt_formatting() {
452        let mut widget = PromptWidget::new();
453        widget.context = ContextIndicators::new()
454            .with_git_branch("main")
455            .with_project_name("ricecoder");
456
457        let prompt = widget.format_prompt();
458        assert!(prompt.contains("main"));
459        assert!(prompt.contains("ricecoder"));
460        assert!(prompt.contains("❯"));
461    }
462
463    #[test]
464    fn test_display_line() {
465        let mut widget = PromptWidget::new();
466        widget.input = "hello".to_string();
467
468        let display = widget.display_line();
469        assert!(display.contains("hello"));
470        assert!(display.contains("❯"));
471    }
472}