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,191 @@
use crate::theme::Theme;
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
Frame,
};
#[derive(Debug, Clone)]
pub enum ChatMessage {
User(String),
Assistant(String),
ToolCall { name: String, args: String },
ToolResult { success: bool, output: String },
System(String),
}
pub struct ChatPanel {
messages: Vec<ChatMessage>,
scroll_offset: usize,
theme: Theme,
}
impl ChatPanel {
pub fn new(theme: Theme) -> Self {
Self {
messages: Vec::new(),
scroll_offset: 0,
theme,
}
}
pub fn add_message(&mut self, message: ChatMessage) {
self.messages.push(message);
// Auto-scroll to bottom on new message
self.scroll_to_bottom();
}
/// Append content to the last assistant message, or create a new one if none exists
pub fn append_to_assistant(&mut self, content: &str) {
if let Some(ChatMessage::Assistant(last_content)) = self.messages.last_mut() {
last_content.push_str(content);
} else {
self.messages.push(ChatMessage::Assistant(content.to_string()));
}
// Auto-scroll to bottom on update
self.scroll_to_bottom();
}
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
pub fn scroll_to_bottom(&mut self) {
self.scroll_offset = self.messages.len().saturating_sub(1);
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
let mut text_lines = Vec::new();
for message in &self.messages {
match message {
ChatMessage::User(content) => {
text_lines.push(Line::from(vec![
Span::styled(" ", self.theme.user_message),
Span::styled(content, self.theme.user_message),
]));
text_lines.push(Line::from(""));
}
ChatMessage::Assistant(content) => {
// Wrap long lines
let wrapped = textwrap::wrap(content, area.width.saturating_sub(6) as usize);
for (i, line) in wrapped.iter().enumerate() {
if i == 0 {
text_lines.push(Line::from(vec![
Span::styled(" ", self.theme.assistant_message),
Span::styled(line.to_string(), self.theme.assistant_message),
]));
} else {
text_lines.push(Line::styled(
format!(" {}", line),
self.theme.assistant_message,
));
}
}
text_lines.push(Line::from(""));
}
ChatMessage::ToolCall { name, args } => {
text_lines.push(Line::from(vec![
Span::styled("", self.theme.tool_call),
Span::styled(
format!("{} ", name),
self.theme.tool_call,
),
Span::styled(
args,
self.theme.tool_call.add_modifier(Modifier::DIM),
),
]));
}
ChatMessage::ToolResult { success, output } => {
let style = if *success {
self.theme.tool_result_success
} else {
self.theme.tool_result_error
};
let icon = if *success { "" } else { "" };
// Truncate long output
let display_output = if output.len() > 200 {
format!("{}... [truncated]", &output[..200])
} else {
output.clone()
};
text_lines.push(Line::from(vec![
Span::styled(icon, style),
Span::raw(" "),
Span::styled(display_output, style.add_modifier(Modifier::DIM)),
]));
text_lines.push(Line::from(""));
}
ChatMessage::System(content) => {
text_lines.push(Line::from(vec![
Span::styled("", Style::default().fg(self.theme.palette.info)),
Span::styled(
content,
Style::default().fg(self.theme.palette.fg_dim),
),
]));
text_lines.push(Line::from(""));
}
}
}
let text = Text::from(text_lines);
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("Chat", self.theme.border_active),
Span::raw(" "),
]));
let paragraph = Paragraph::new(text)
.block(block)
.scroll((self.scroll_offset as u16, 0));
frame.render_widget(paragraph, area);
// Render scrollbar if needed
if self.messages.len() > area.height as usize {
let scrollbar = Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some(""))
.end_symbol(Some(""))
.track_symbol(Some(""))
.thumb_symbol("")
.style(self.theme.border);
let mut scrollbar_state = ScrollbarState::default()
.content_length(self.messages.len())
.position(self.scroll_offset);
frame.render_stateful_widget(
scrollbar,
area,
&mut scrollbar_state,
);
}
}
pub fn messages(&self) -> &[ChatMessage] {
&self.messages
}
pub fn clear(&mut self) {
self.messages.clear();
self.scroll_offset = 0;
}
}