1use std::io::{self, Write};
6
7pub struct InputComposer {
10 history: Vec<String>,
12 history_pos: Option<usize>,
14 commands: Vec<String>,
16 buffer: Vec<String>,
18 cursor_row: usize,
20 cursor_col: usize,
21 prompt: String,
23 cont_prompt: String,
25}
26
27impl InputComposer {
28 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 pub fn add_commands(&mut self, cmds: &[&str]) {
55 for cmd in cmds {
56 self.commands.push(cmd.to_string());
57 }
58 }
59
60 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 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 break;
87 }
88
89 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
90
91 if first && trimmed.is_empty() {
92 return Ok(String::new());
94 }
95
96 if !first && trimmed.is_empty() {
97 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 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 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 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 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}