feat(ui): add TUI with streaming agent integration and theming

Add a new terminal UI crate (crates/app/ui) built with ratatui providing an
interactive chat interface with real-time LLM streaming and tool visualization.

Features:
- Chat panel with horizontal padding for improved readability
- Input box with cursor navigation and command history
- Status bar with session statistics and uniform background styling
- 7 theme presets: Tokyo Night (default), Dracula, Catppuccin, Nord,
  Synthwave, Rose Pine, and Midnight Ocean
- Theme switching via /theme <name> and /themes commands
- Streaming LLM responses that accumulate into single messages
- Real-time tool call visualization with success/error states
- Session tracking (messages, tokens, tool calls, duration)
- REPL commands: /help, /status, /cost, /checkpoint, /rewind, /clear, /exit

Integration:
- CLI automatically launches TUI mode when running interactively (no prompt)
- Falls back to legacy text REPL with --no-tui flag
- Uses existing agent loop with streaming support
- Supports all existing tools (read, write, edit, glob, grep, bash)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-01 22:57:25 +01:00
parent 5caf502009
commit 09c8c9d83e
14 changed files with 1614 additions and 3 deletions

View File

@@ -0,0 +1,144 @@
use crate::theme::Theme;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Padding, Paragraph},
Frame,
};
pub struct InputBox {
input: String,
cursor_position: usize,
history: Vec<String>,
history_index: usize,
theme: Theme,
}
impl InputBox {
pub fn new(theme: Theme) -> Self {
Self {
input: String::new(),
cursor_position: 0,
history: Vec::new(),
history_index: 0,
theme,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Option<String> {
match key.code {
KeyCode::Enter => {
let message = self.input.clone();
if !message.trim().is_empty() {
self.history.push(message.clone());
self.history_index = self.history.len();
self.input.clear();
self.cursor_position = 0;
return Some(message);
}
}
KeyCode::Char(c) => {
self.input.insert(self.cursor_position, c);
self.cursor_position += 1;
}
KeyCode::Backspace => {
if self.cursor_position > 0 {
self.input.remove(self.cursor_position - 1);
self.cursor_position -= 1;
}
}
KeyCode::Delete => {
if self.cursor_position < self.input.len() {
self.input.remove(self.cursor_position);
}
}
KeyCode::Left => {
self.cursor_position = self.cursor_position.saturating_sub(1);
}
KeyCode::Right => {
if self.cursor_position < self.input.len() {
self.cursor_position += 1;
}
}
KeyCode::Home => {
self.cursor_position = 0;
}
KeyCode::End => {
self.cursor_position = self.input.len();
}
KeyCode::Up => {
if !self.history.is_empty() && self.history_index > 0 {
self.history_index -= 1;
self.input = self.history[self.history_index].clone();
self.cursor_position = self.input.len();
}
}
KeyCode::Down => {
if self.history_index < self.history.len() - 1 {
self.history_index += 1;
self.input = self.history[self.history_index].clone();
self.cursor_position = self.input.len();
} else if self.history_index < self.history.len() {
self.history_index = self.history.len();
self.input.clear();
self.cursor_position = 0;
}
}
_ => {}
}
None
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
let is_empty = self.input.is_empty();
let block = Block::default()
.borders(Borders::ALL)
.border_style(self.theme.border_active)
.padding(Padding::horizontal(1))
.title(Line::from(vec![
Span::raw(" "),
Span::styled("", self.theme.border_active),
Span::raw(" "),
Span::styled("Input", self.theme.border_active),
Span::raw(" "),
]));
// Display input with cursor
let (text_before, text_after) = if self.cursor_position < self.input.len() {
(
&self.input[..self.cursor_position],
&self.input[self.cursor_position..],
)
} else {
(&self.input[..], "")
};
let line = if is_empty {
Line::from(vec![
Span::styled(" ", self.theme.input_box_active),
Span::styled("", self.theme.input_box_active),
Span::styled(" Type a message...", Style::default().fg(self.theme.palette.fg_dim)),
])
} else {
Line::from(vec![
Span::styled(" ", self.theme.input_box_active),
Span::styled(text_before, self.theme.input_box),
Span::styled("", self.theme.input_box_active),
Span::styled(text_after, self.theme.input_box),
])
};
let paragraph = Paragraph::new(line).block(block);
frame.render_widget(paragraph, area);
}
pub fn clear(&mut self) {
self.input.clear();
self.cursor_position = 0;
}
}