Skip to main content

codetether_agent/tui/
mod.rs

1//! Terminal User Interface
2//!
3//! Interactive TUI using Ratatui
4
5use 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
22/// Run the TUI
23pub async fn run(project: Option<PathBuf>) -> Result<()> {
24    // Change to project directory if specified
25    if let Some(dir) = project {
26        std::env::set_current_dir(&dir)?;
27    }
28
29    // Setup terminal
30    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    // Run the app
37    let result = run_app(&mut terminal).await;
38
39    // Restore terminal
40    disable_raw_mode()?;
41    execute!(
42        terminal.backend_mut(),
43        LeaveAlternateScreen,
44        DisableMouseCapture
45    )?;
46    terminal.show_cursor()?;
47
48    result
49}
50
51/// Application state
52struct 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        // Add user message
92        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        // TODO: Actually process the message with the agent
99        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                // Help overlay
116                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                    // Quit
125                    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                    // Help
133                    KeyCode::Char('?') => {
134                        app.show_help = true;
135                    }
136
137                    // Switch agent
138                    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                    // Submit message
147                    KeyCode::Enter => {
148                        app.submit_message();
149                    }
150
151                    // Text input
152                    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                    // Scroll
183                    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),      // Messages
208            Constraint::Length(3),   // Input
209            Constraint::Length(1),   // Status bar
210        ])
211        .split(f.area());
212
213    // Messages area
214    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    // Input area
245    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    // Cursor
256    f.set_cursor_position((
257        chunks[1].x + app.cursor_position as u16 + 1,
258        chunks[1].y + 1,
259    ));
260
261    // Status bar
262    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    // Help overlay
278    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
318/// Helper to create a centered rect
319fn 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}