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