1use anyhow::Result;
6use crossterm::{
7 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
8 execute,
9 terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
10};
11use ratatui::{
12 backend::CrosstermBackend,
13 layout::{Constraint, Direction, Layout, Rect},
14 style::{Color, Modifier, Style},
15 text::{Line, Span},
16 widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
17 Frame, Terminal,
18};
19use std::io;
20use std::path::PathBuf;
21
22pub async fn run(project: Option<PathBuf>) -> Result<()> {
24 if let Some(dir) = project {
26 std::env::set_current_dir(&dir)?;
27 }
28
29 enable_raw_mode()?;
31 let mut stdout = io::stdout();
32 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
33 let backend = CrosstermBackend::new(stdout);
34 let mut terminal = Terminal::new(backend)?;
35
36 let result = run_app(&mut terminal).await;
38
39 disable_raw_mode()?;
41 execute!(
42 terminal.backend_mut(),
43 LeaveAlternateScreen,
44 DisableMouseCapture
45 )?;
46 terminal.show_cursor()?;
47
48 result
49}
50
51struct App {
53 input: String,
54 cursor_position: usize,
55 messages: Vec<ChatMessage>,
56 current_agent: String,
57 scroll: usize,
58 show_help: bool,
59}
60
61struct ChatMessage {
62 role: String,
63 content: String,
64 timestamp: String,
65}
66
67impl App {
68 fn new() -> Self {
69 Self {
70 input: String::new(),
71 cursor_position: 0,
72 messages: vec![ChatMessage {
73 role: "system".to_string(),
74 content: "Welcome to CodeTether Agent! Type a message to get started, or press ? for help.".to_string(),
75 timestamp: chrono::Local::now().format("%H:%M").to_string(),
76 }],
77 current_agent: "build".to_string(),
78 scroll: 0,
79 show_help: false,
80 }
81 }
82
83 fn submit_message(&mut self) {
84 if self.input.is_empty() {
85 return;
86 }
87
88 let message = std::mem::take(&mut self.input);
89 self.cursor_position = 0;
90
91 self.messages.push(ChatMessage {
93 role: "user".to_string(),
94 content: message.clone(),
95 timestamp: chrono::Local::now().format("%H:%M").to_string(),
96 });
97
98 self.messages.push(ChatMessage {
100 role: "assistant".to_string(),
101 content: format!("[Agent processing not yet implemented]\n\nYou said: {}", message),
102 timestamp: chrono::Local::now().format("%H:%M").to_string(),
103 });
104 }
105}
106
107async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
108 let mut app = App::new();
109
110 loop {
111 terminal.draw(|f| ui(f, &app))?;
112
113 if event::poll(std::time::Duration::from_millis(100))? {
114 if let Event::Key(key) = event::read()? {
115 if app.show_help {
117 if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
118 app.show_help = false;
119 }
120 continue;
121 }
122
123 match key.code {
124 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
126 return Ok(());
127 }
128 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
129 return Ok(());
130 }
131
132 KeyCode::Char('?') => {
134 app.show_help = true;
135 }
136
137 KeyCode::Tab => {
139 app.current_agent = if app.current_agent == "build" {
140 "plan".to_string()
141 } else {
142 "build".to_string()
143 };
144 }
145
146 KeyCode::Enter => {
148 app.submit_message();
149 }
150
151 KeyCode::Char(c) => {
153 app.input.insert(app.cursor_position, c);
154 app.cursor_position += 1;
155 }
156 KeyCode::Backspace => {
157 if app.cursor_position > 0 {
158 app.cursor_position -= 1;
159 app.input.remove(app.cursor_position);
160 }
161 }
162 KeyCode::Delete => {
163 if app.cursor_position < app.input.len() {
164 app.input.remove(app.cursor_position);
165 }
166 }
167 KeyCode::Left => {
168 app.cursor_position = app.cursor_position.saturating_sub(1);
169 }
170 KeyCode::Right => {
171 if app.cursor_position < app.input.len() {
172 app.cursor_position += 1;
173 }
174 }
175 KeyCode::Home => {
176 app.cursor_position = 0;
177 }
178 KeyCode::End => {
179 app.cursor_position = app.input.len();
180 }
181
182 KeyCode::Up => {
184 app.scroll = app.scroll.saturating_sub(1);
185 }
186 KeyCode::Down => {
187 app.scroll = app.scroll.saturating_add(1);
188 }
189 KeyCode::PageUp => {
190 app.scroll = app.scroll.saturating_sub(10);
191 }
192 KeyCode::PageDown => {
193 app.scroll = app.scroll.saturating_add(10);
194 }
195
196 _ => {}
197 }
198 }
199 }
200 }
201}
202
203fn ui(f: &mut Frame, app: &App) {
204 let chunks = Layout::default()
205 .direction(Direction::Vertical)
206 .constraints([
207 Constraint::Min(1), Constraint::Length(3), Constraint::Length(1), ])
211 .split(f.area());
212
213 let messages_block = Block::default()
215 .borders(Borders::ALL)
216 .title(format!(" CodeTether Agent [{}] ", app.current_agent))
217 .border_style(Style::default().fg(Color::Cyan));
218
219 let messages: Vec<ListItem> = app
220 .messages
221 .iter()
222 .map(|m| {
223 let style = match m.role.as_str() {
224 "user" => Style::default().fg(Color::Green),
225 "assistant" => Style::default().fg(Color::Blue),
226 "system" => Style::default().fg(Color::Yellow),
227 _ => Style::default(),
228 };
229
230 let header = Line::from(vec![
231 Span::styled(format!("[{}] ", m.timestamp), Style::default().fg(Color::DarkGray)),
232 Span::styled(&m.role, style.add_modifier(Modifier::BOLD)),
233 ]);
234
235 let content = Line::from(Span::raw(&m.content));
236
237 ListItem::new(vec![header, content, Line::from("")])
238 })
239 .collect();
240
241 let messages_list = List::new(messages).block(messages_block);
242 f.render_widget(messages_list, chunks[0]);
243
244 let input_block = Block::default()
246 .borders(Borders::ALL)
247 .title(" Message (Enter to send) ")
248 .border_style(Style::default().fg(Color::White));
249
250 let input = Paragraph::new(app.input.as_str())
251 .block(input_block)
252 .wrap(Wrap { trim: false });
253 f.render_widget(input, chunks[1]);
254
255 f.set_cursor_position((
257 chunks[1].x + app.cursor_position as u16 + 1,
258 chunks[1].y + 1,
259 ));
260
261 let cwd = std::env::current_dir()
263 .map(|p| p.display().to_string())
264 .unwrap_or_else(|_| "?".to_string());
265
266 let status = Paragraph::new(Line::from(vec![
267 Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
268 Span::raw(" Help "),
269 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
270 Span::raw(" Switch Agent "),
271 Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::White)),
272 Span::raw(" Quit "),
273 Span::styled(format!(" {} ", cwd), Style::default().fg(Color::DarkGray)),
274 ]));
275 f.render_widget(status, chunks[2]);
276
277 if app.show_help {
279 let area = centered_rect(60, 60, f.area());
280 f.render_widget(Clear, area);
281
282 let help_text = vec![
283 "",
284 " KEYBOARD SHORTCUTS",
285 " ==================",
286 "",
287 " Enter Send message",
288 " Tab Switch between build/plan agents",
289 " Ctrl+C Quit",
290 " ? Toggle this help",
291 "",
292 " Up/Down Scroll messages",
293 " PageUp/Down Scroll faster",
294 "",
295 " AGENTS",
296 " ======",
297 "",
298 " build Full access for development work",
299 " plan Read-only for analysis & exploration",
300 "",
301 " Press ? or Esc to close",
302 "",
303 ];
304
305 let help = Paragraph::new(help_text.join("\n"))
306 .block(
307 Block::default()
308 .borders(Borders::ALL)
309 .title(" Help ")
310 .border_style(Style::default().fg(Color::Yellow)),
311 )
312 .wrap(Wrap { trim: false });
313
314 f.render_widget(help, area);
315 }
316}
317
318fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
320 let popup_layout = Layout::default()
321 .direction(Direction::Vertical)
322 .constraints([
323 Constraint::Percentage((100 - percent_y) / 2),
324 Constraint::Percentage(percent_y),
325 Constraint::Percentage((100 - percent_y) / 2),
326 ])
327 .split(r);
328
329 Layout::default()
330 .direction(Direction::Horizontal)
331 .constraints([
332 Constraint::Percentage((100 - percent_x) / 2),
333 Constraint::Percentage(percent_x),
334 Constraint::Percentage((100 - percent_x) / 2),
335 ])
336 .split(popup_layout[1])[1]
337}