Skip to main content

sparrow/chat/
composer.rs

1//! Input composer for the Sparrow chat.
2//!
3//! Manages multi-line input with history navigation and basic editing.
4
5use std::io::{self, Write};
6
7/// A simple input composer that supports multi-line input,
8/// history navigation, and basic slash-command completion.
9pub struct InputComposer {
10    /// Command history (most recent first)
11    history: Vec<String>,
12    /// Current history position (None = not navigating)
13    history_pos: Option<usize>,
14    /// Known slash commands for auto-completion
15    commands: Vec<String>,
16    /// Current input buffer (lines) — reserved for future TUI-driven editing.
17    #[allow(dead_code)]
18    buffer: Vec<String>,
19    /// Current cursor position (row, col) — reserved for future TUI-driven editing.
20    #[allow(dead_code)]
21    cursor_row: usize,
22    #[allow(dead_code)]
23    cursor_col: usize,
24    /// Prompt string
25    prompt: String,
26    /// Continuation prompt for multi-line
27    cont_prompt: String,
28}
29
30impl InputComposer {
31    /// Create a new input composer.
32    pub fn new() -> Self {
33        Self {
34            history: Vec::new(),
35            history_pos: None,
36            commands: vec![
37                "/help".into(),
38                "/plan".into(),
39                "/run".into(),
40                "/chat".into(),
41                "/clear".into(),
42                "/history".into(),
43                "/save".into(),
44                "/load".into(),
45                "/exit".into(),
46                "/quit".into(),
47            ],
48            buffer: vec![String::new()],
49            cursor_row: 0,
50            cursor_col: 0,
51            prompt: "> ".into(),
52            cont_prompt: ". ".into(),
53        }
54    }
55
56    /// Add commands to the autocomplete list.
57    pub fn add_commands(&mut self, cmds: &[&str]) {
58        for cmd in cmds {
59            self.commands.push(cmd.to_string());
60        }
61    }
62
63    /// Add a line to history.
64    pub fn add_history(&mut self, line: &str) {
65        if !line.trim().is_empty() {
66            self.history.insert(0, line.to_string());
67            if self.history.len() > 1000 {
68                self.history.pop();
69            }
70        }
71        self.history_pos = None;
72    }
73
74    /// Read a complete input (terminated by empty line or Ctrl+D).
75    pub fn read_input(&mut self) -> anyhow::Result<String> {
76        let mut lines: Vec<String> = Vec::new();
77        let mut first = true;
78
79        loop {
80            let prompt = if first {
81                &self.prompt
82            } else {
83                &self.cont_prompt
84            };
85            print!("{prompt}");
86            io::stdout().flush()?;
87
88            let mut line = String::new();
89            let bytes = io::stdin().read_line(&mut line)?;
90
91            if bytes == 0 {
92                // EOF (Ctrl+D)
93                break;
94            }
95
96            let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
97
98            if first && trimmed.is_empty() {
99                // Empty first line = cancel
100                return Ok(String::new());
101            }
102
103            if !first && trimmed.is_empty() {
104                // Empty continuation line = submit
105                break;
106            }
107
108            let text = trimmed.to_string();
109            if first {
110                self.add_history(&text);
111            }
112            lines.push(text);
113            first = false;
114        }
115
116        Ok(lines.join("\n"))
117    }
118
119    /// Auto-complete a partial slash command.
120    pub fn autocomplete(&self, partial: &str) -> Vec<String> {
121        if !partial.starts_with('/') {
122            return Vec::new();
123        }
124
125        let lower = partial.to_lowercase();
126        self.commands
127            .iter()
128            .filter(|cmd| cmd.to_lowercase().starts_with(&lower))
129            .cloned()
130            .collect()
131    }
132
133    /// Navigate history up (older entry).
134    pub fn history_up(&mut self) -> Option<&str> {
135        if self.history.is_empty() {
136            return None;
137        }
138        let pos = self
139            .history_pos
140            .map_or(0, |p| (p + 1).min(self.history.len() - 1));
141        self.history_pos = Some(pos);
142        Some(&self.history[pos])
143    }
144
145    /// Navigate history down (newer entry).
146    pub fn history_down(&mut self) -> Option<&str> {
147        match self.history_pos {
148            Some(0) | None => {
149                self.history_pos = None;
150                None
151            }
152            Some(p) => {
153                self.history_pos = Some(p - 1);
154                Some(&self.history[p - 1])
155            }
156        }
157    }
158
159    /// Get the command history.
160    pub fn get_history(&self) -> &[String] {
161        &self.history
162    }
163}
164
165impl Default for InputComposer {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_autocomplete() {
177        let composer = InputComposer::new();
178        let matches = composer.autocomplete("/he");
179        assert!(matches.contains(&"/help".to_string()));
180    }
181
182    #[test]
183    fn test_history() {
184        let mut composer = InputComposer::new();
185        composer.add_history("hello");
186        composer.add_history("world");
187
188        // Newest first (history is stored most-recent-first).
189        let up = composer.history_up();
190        assert_eq!(up, Some("world"));
191
192        // Going further back returns the older entry.
193        let up2 = composer.history_up();
194        assert_eq!(up2, Some("hello"));
195
196        // Going back down returns the newer entry again.
197        let down = composer.history_down();
198        assert_eq!(down, Some("world"));
199
200        // Past the newest, navigation resets.
201        let none = composer.history_down();
202        assert_eq!(none, None);
203    }
204}