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;
}
}

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;
}
}

View File

@@ -0,0 +1,9 @@
mod chat_panel;
mod input_box;
mod permission_popup;
mod status_bar;
pub use chat_panel::{ChatMessage, ChatPanel};
pub use input_box::InputBox;
pub use permission_popup::{PermissionOption, PermissionPopup};
pub use status_bar::StatusBar;

View File

@@ -0,0 +1,196 @@
use crate::theme::Theme;
use crossterm::event::{KeyCode, KeyEvent};
use permissions::PermissionDecision;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
#[derive(Debug, Clone)]
pub enum PermissionOption {
AllowOnce,
AlwaysAllow,
Deny,
Explain,
}
pub struct PermissionPopup {
tool: String,
context: Option<String>,
selected: usize,
theme: Theme,
}
impl PermissionPopup {
pub fn new(tool: String, context: Option<String>, theme: Theme) -> Self {
Self {
tool,
context,
selected: 0,
theme,
}
}
pub fn handle_key(&mut self, key: KeyEvent) -> Option<PermissionOption> {
match key.code {
KeyCode::Char('a') => Some(PermissionOption::AllowOnce),
KeyCode::Char('A') => Some(PermissionOption::AlwaysAllow),
KeyCode::Char('d') => Some(PermissionOption::Deny),
KeyCode::Char('?') => Some(PermissionOption::Explain),
KeyCode::Up => {
self.selected = self.selected.saturating_sub(1);
None
}
KeyCode::Down => {
if self.selected < 3 {
self.selected += 1;
}
None
}
KeyCode::Enter => match self.selected {
0 => Some(PermissionOption::AllowOnce),
1 => Some(PermissionOption::AlwaysAllow),
2 => Some(PermissionOption::Deny),
3 => Some(PermissionOption::Explain),
_ => None,
},
KeyCode::Esc => Some(PermissionOption::Deny),
_ => None,
}
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
// Center the popup
let popup_area = crate::layout::AppLayout::center_popup(area, 64, 14);
// Clear the area behind the popup
frame.render_widget(Clear, popup_area);
// Render popup with styled border
let block = Block::default()
.borders(Borders::ALL)
.border_style(self.theme.popup_border)
.style(self.theme.popup_bg)
.title(Line::from(vec![
Span::raw(" "),
Span::styled("🔒", self.theme.popup_title),
Span::raw(" "),
Span::styled("Permission Required", self.theme.popup_title),
Span::raw(" "),
]));
frame.render_widget(block, popup_area);
// Split popup into sections
let inner = popup_area.inner(ratatui::layout::Margin {
vertical: 1,
horizontal: 2,
});
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), // Tool name with box
Constraint::Length(3), // Context (if any)
Constraint::Length(1), // Separator
Constraint::Length(1), // Option 1
Constraint::Length(1), // Option 2
Constraint::Length(1), // Option 3
Constraint::Length(1), // Option 4
Constraint::Length(1), // Help text
])
.split(inner);
// Tool name with highlight
let tool_line = Line::from(vec![
Span::styled("⚡ Tool: ", Style::default().fg(self.theme.palette.warning)),
Span::styled(&self.tool, self.theme.popup_title),
]);
frame.render_widget(Paragraph::new(tool_line), sections[0]);
// Context with wrapping
if let Some(ctx) = &self.context {
let context_text = if ctx.len() > 100 {
format!("{}...", &ctx[..100])
} else {
ctx.clone()
};
let context_lines = textwrap::wrap(&context_text, (sections[1].width - 2) as usize);
let mut lines = vec![
Line::from(vec![
Span::styled("📝 Context: ", Style::default().fg(self.theme.palette.info)),
])
];
for line in context_lines.iter().take(2) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(line.to_string(), Style::default().fg(self.theme.palette.fg_dim)),
]));
}
frame.render_widget(Paragraph::new(lines), sections[1]);
}
// Separator
let separator = Line::styled(
"".repeat(sections[2].width as usize),
Style::default().fg(self.theme.palette.border),
);
frame.render_widget(Paragraph::new(separator), sections[2]);
// Options with icons and colors
let options = [
("", " [a] Allow once", self.theme.palette.success, 0),
("✓✓", " [A] Always allow", self.theme.palette.primary, 1),
("", " [d] Deny", self.theme.palette.error, 2),
("?", " [?] Explain", self.theme.palette.info, 3),
];
for (icon, text, color, idx) in options.iter() {
let (style, prefix) = if self.selected == *idx {
(
self.theme.selected,
""
)
} else {
(
Style::default().fg(*color),
" "
)
};
let line = Line::from(vec![
Span::styled(prefix, style),
Span::styled(*icon, style),
Span::styled(*text, style),
]);
frame.render_widget(Paragraph::new(line), sections[3 + idx]);
}
// Help text at bottom
let help_line = Line::from(vec![
Span::styled(
"↑↓ Navigate Enter to select Esc to deny",
Style::default().fg(self.theme.palette.fg_dim).add_modifier(Modifier::ITALIC),
),
]);
frame.render_widget(Paragraph::new(help_line), sections[7]);
}
}
impl PermissionOption {
pub fn to_decision(&self) -> Option<PermissionDecision> {
match self {
PermissionOption::AllowOnce => Some(PermissionDecision::Allow),
PermissionOption::AlwaysAllow => Some(PermissionDecision::Allow),
PermissionOption::Deny => Some(PermissionDecision::Deny),
PermissionOption::Explain => None, // Special handling needed
}
}
pub fn should_persist(&self) -> bool {
matches!(self, PermissionOption::AlwaysAllow)
}
}

View File

@@ -0,0 +1,109 @@
use crate::theme::Theme;
use agent_core::SessionStats;
use permissions::Mode;
use ratatui::{
layout::Rect,
text::{Line, Span},
widgets::Paragraph,
Frame,
};
pub struct StatusBar {
model: String,
mode: Mode,
stats: SessionStats,
last_tool: Option<String>,
theme: Theme,
}
impl StatusBar {
pub fn new(model: String, mode: Mode, theme: Theme) -> Self {
Self {
model,
mode,
stats: SessionStats::new(),
last_tool: None,
theme,
}
}
pub fn update_stats(&mut self, stats: SessionStats) {
self.stats = stats;
}
pub fn set_last_tool(&mut self, tool: String) {
self.last_tool = Some(tool);
}
pub fn render(&self, frame: &mut Frame, area: Rect) {
let elapsed = self.stats.start_time.elapsed().unwrap_or_default();
let elapsed_str = SessionStats::format_duration(elapsed);
let (mode_str, mode_icon) = match self.mode {
Mode::Plan => ("Plan", "🔍"),
Mode::AcceptEdits => ("AcceptEdits", "✏️"),
Mode::Code => ("Code", ""),
};
let last_tool_str = self
.last_tool
.as_ref()
.map(|t| format!("{}", t))
.unwrap_or_else(|| "○ idle".to_string());
// Build status line with colorful sections
let separator_style = self.theme.status_bar;
let mut spans = vec![
Span::styled(" ", separator_style),
Span::styled(mode_icon, self.theme.status_bar),
Span::styled(" ", separator_style),
Span::styled(mode_str, self.theme.status_bar),
Span::styled("", separator_style),
Span::styled("", self.theme.status_bar),
Span::styled(" ", separator_style),
Span::styled(&self.model, self.theme.status_bar),
Span::styled("", separator_style),
Span::styled(
format!("{} msgs", self.stats.total_messages),
self.theme.status_bar,
),
Span::styled("", separator_style),
Span::styled(
format!("{} tools", self.stats.total_tool_calls),
self.theme.status_bar,
),
Span::styled("", separator_style),
Span::styled(
format!("~{} tok", self.stats.estimated_tokens),
self.theme.status_bar,
),
Span::styled("", separator_style),
Span::styled("", self.theme.status_bar),
Span::styled(" ", separator_style),
Span::styled(elapsed_str, self.theme.status_bar),
Span::styled("", separator_style),
Span::styled(last_tool_str, self.theme.status_bar),
];
// Add help text on the right
let help_text = " ? /help ";
// Calculate current length
let current_len: usize = spans.iter()
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
.sum();
// Add padding
let padding = area
.width
.saturating_sub((current_len + help_text.len()) as u16);
spans.push(Span::styled(" ".repeat(padding as usize), separator_style));
spans.push(Span::styled(help_text, self.theme.status_bar));
let line = Line::from(spans);
let paragraph = Paragraph::new(line);
frame.render_widget(paragraph, area);
}
}