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:
144
crates/app/ui/src/components/input_box.rs
Normal file
144
crates/app/ui/src/components/input_box.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user