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

@@ -19,6 +19,8 @@ tools-slash = { path = "../../tools/slash" }
config-agent = { package = "config-agent", path = "../../platform/config" }
permissions = { path = "../../platform/permissions" }
hooks = { path = "../../platform/hooks" }
ui = { path = "../ui" }
atty = "0.2"
futures-util = "0.3.31"
[dev-dependencies]

View File

@@ -149,6 +149,9 @@ struct Args {
/// Output format (text, json, stream-json)
#[arg(long, value_enum, default_value = "text")]
output_format: OutputFormat,
/// Disable TUI and use legacy text-based REPL
#[arg(long)]
no_tui: bool,
#[arg()]
prompt: Vec<String>,
#[command(subcommand)]
@@ -434,15 +437,15 @@ async fn main() -> Result<()> {
}
}
let model = args.model.unwrap_or(settings.model);
let api_key = args.api_key.or(settings.api_key);
let model = args.model.unwrap_or(settings.model.clone());
let api_key = args.api_key.or(settings.api_key.clone());
// Use Ollama Cloud when model has "-cloud" suffix AND API key is set
let use_cloud = model.ends_with("-cloud") && api_key.is_some();
let client = if use_cloud {
OllamaClient::with_cloud().with_api_key(api_key.unwrap())
} else {
let base_url = args.ollama_url.unwrap_or(settings.ollama_url);
let base_url = args.ollama_url.unwrap_or(settings.ollama_url.clone());
let mut client = OllamaClient::new(base_url);
if let Some(key) = api_key {
client = client.with_api_key(key);
@@ -456,6 +459,13 @@ async fn main() -> Result<()> {
// Check if interactive mode (no prompt provided)
if args.prompt.is_empty() {
// Use TUI mode unless --no-tui flag is set or not a TTY
if !args.no_tui && atty::is(atty::Stream::Stdout) {
// Launch TUI
return ui::run(client, opts, perms, settings).await;
}
// Legacy text-based REPL
println!("🤖 Owlen Interactive Mode");
println!("Model: {}", opts.model);
println!("Mode: {:?}", settings.mode);

23
crates/app/ui/Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[package]
name = "ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[dependencies]
color-eyre = "0.6"
crossterm = { version = "0.28", features = ["event-stream"] }
ratatui = "0.28"
tokio = { version = "1", features = ["full"] }
futures = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
unicode-width = "0.2"
textwrap = "0.16"
# Internal dependencies
agent-core = { path = "../../core/agent" }
permissions = { path = "../../platform/permissions" }
llm-ollama = { path = "../../llm/ollama" }
config-agent = { path = "../../platform/config" }

561
crates/app/ui/src/app.rs Normal file
View File

@@ -0,0 +1,561 @@
use crate::{
components::{ChatMessage, ChatPanel, InputBox, PermissionPopup, StatusBar},
events::{handle_key_event, AppEvent},
layout::AppLayout,
theme::Theme,
};
use agent_core::{CheckpointManager, SessionHistory, SessionStats, execute_tool, get_tool_definitions};
use color_eyre::eyre::Result;
use crossterm::{
event::{Event, EventStream},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use futures::{StreamExt, TryStreamExt};
use llm_ollama::{ChatMessage as LLMChatMessage, OllamaClient, OllamaOptions};
use permissions::PermissionManager;
use ratatui::{backend::CrosstermBackend, Terminal};
use std::{io::stdout, path::PathBuf, time::SystemTime};
use tokio::sync::mpsc;
pub struct TuiApp {
// UI components
chat_panel: ChatPanel,
input_box: InputBox,
status_bar: StatusBar,
permission_popup: Option<PermissionPopup>,
theme: Theme,
// Session state
stats: SessionStats,
history: SessionHistory,
checkpoint_mgr: CheckpointManager,
// System state
client: OllamaClient,
opts: OllamaOptions,
perms: PermissionManager,
#[allow(dead_code)]
settings: config_agent::Settings,
// Runtime state
running: bool,
waiting_for_llm: bool,
}
impl TuiApp {
pub fn new(
client: OllamaClient,
opts: OllamaOptions,
perms: PermissionManager,
settings: config_agent::Settings,
) -> Result<Self> {
let theme = Theme::default();
let mode = perms.mode();
Ok(Self {
chat_panel: ChatPanel::new(theme.clone()),
input_box: InputBox::new(theme.clone()),
status_bar: StatusBar::new(opts.model.clone(), mode, theme.clone()),
permission_popup: None,
theme,
stats: SessionStats::new(),
history: SessionHistory::new(),
checkpoint_mgr: CheckpointManager::new(PathBuf::from(".owlen/checkpoints")),
client,
opts,
perms,
settings,
running: true,
waiting_for_llm: false,
})
}
fn set_theme(&mut self, theme: Theme) {
self.theme = theme.clone();
self.chat_panel = ChatPanel::new(theme.clone());
self.input_box = InputBox::new(theme.clone());
self.status_bar = StatusBar::new(self.opts.model.clone(), self.perms.mode(), theme);
}
pub async fn run(&mut self) -> Result<()> {
// Setup terminal
enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
// Create event channel
let (event_tx, mut event_rx) = mpsc::unbounded_channel();
// Spawn terminal event listener
let tx_clone = event_tx.clone();
tokio::spawn(async move {
let mut reader = EventStream::new();
while let Some(event) = reader.next().await {
if let Ok(Event::Key(key)) = event {
if let Some(app_event) = handle_key_event(key) {
let _ = tx_clone.send(app_event);
}
} else if let Ok(Event::Resize(w, h)) = event {
let _ = tx_clone.send(AppEvent::Resize {
width: w,
height: h,
});
}
}
});
// Add welcome message
self.chat_panel.add_message(ChatMessage::System(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
format!("Welcome to Owlen - Your AI Coding Assistant")
));
self.chat_panel.add_message(ChatMessage::System(
format!("Model: {} │ Mode: {:?} │ Theme: Tokyo Night", self.opts.model, self.perms.mode())
));
self.chat_panel.add_message(ChatMessage::System(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
"Type your message or use /help for available commands".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
"Press Ctrl+C to exit anytime".to_string()
));
// Main event loop
while self.running {
// Render
terminal.draw(|frame| {
let size = frame.area();
let layout = AppLayout::calculate(size);
// Render main components
self.chat_panel.render(frame, layout.chat_area);
self.input_box.render(frame, layout.input_area);
self.status_bar.render(frame, layout.status_area);
// Render permission popup if active
if let Some(popup) = &self.permission_popup {
popup.render(frame, size);
}
})?;
// Handle events
if let Ok(event) = event_rx.try_recv() {
self.handle_event(event, &event_tx).await?;
}
// Small delay to prevent busy-waiting
tokio::time::sleep(tokio::time::Duration::from_millis(16)).await;
}
// Cleanup terminal
disable_raw_mode()?;
stdout().execute(LeaveAlternateScreen)?;
Ok(())
}
async fn handle_event(
&mut self,
event: AppEvent,
event_tx: &mpsc::UnboundedSender<AppEvent>,
) -> Result<()> {
match event {
AppEvent::Input(key) => {
// If permission popup is active, handle there first
if let Some(popup) = &mut self.permission_popup {
if let Some(option) = popup.handle_key(key) {
// TODO: Handle permission decision
self.chat_panel.add_message(ChatMessage::System(
format!("Permission: {:?}", option)
));
self.permission_popup = None;
}
} else {
// Handle input box
if let Some(message) = self.input_box.handle_key(key) {
self.handle_user_message(message, event_tx).await?;
}
}
}
AppEvent::UserMessage(message) => {
self.chat_panel
.add_message(ChatMessage::User(message.clone()));
self.history.add_user_message(message);
}
AppEvent::LlmChunk(chunk) => {
// Add to last assistant message or create new one
self.chat_panel.add_message(ChatMessage::Assistant(chunk));
}
AppEvent::ToolCall { name, args } => {
self.chat_panel.add_message(ChatMessage::ToolCall {
name: name.clone(),
args: args.to_string(),
});
self.status_bar.set_last_tool(name);
self.stats.record_tool_call();
}
AppEvent::ToolResult { success, output } => {
self.chat_panel
.add_message(ChatMessage::ToolResult { success, output });
}
AppEvent::PermissionRequest { tool, context } => {
self.permission_popup =
Some(PermissionPopup::new(tool, context, self.theme.clone()));
}
AppEvent::StatusUpdate(stats) => {
self.stats = stats.clone();
self.status_bar.update_stats(stats);
}
AppEvent::Resize { .. } => {
// Terminal will automatically re-layout on next draw
}
AppEvent::Quit => {
self.running = false;
}
}
Ok(())
}
async fn handle_user_message(
&mut self,
message: String,
_event_tx: &mpsc::UnboundedSender<AppEvent>,
) -> Result<()> {
// Handle slash commands
if message.starts_with('/') {
self.handle_command(&message)?;
return Ok(());
}
// Add user message to chat
self.chat_panel
.add_message(ChatMessage::User(message.clone()));
self.history.add_user_message(message.clone());
// Run agent loop with tool calling
self.waiting_for_llm = true;
let start = SystemTime::now();
match self.run_streaming_agent_loop(&message).await {
Ok(response) => {
self.history.add_assistant_message(response.clone());
// Update stats
let duration = start.elapsed().unwrap_or_default();
let tokens = (message.len() + response.len()) / 4; // Rough estimate
self.stats.record_message(tokens, duration);
}
Err(e) => {
self.chat_panel.add_message(ChatMessage::System(
format!("❌ Error: {}", e)
));
}
}
self.waiting_for_llm = false;
Ok(())
}
async fn run_streaming_agent_loop(&mut self, user_prompt: &str) -> Result<String> {
let tools = get_tool_definitions();
let mut messages = vec![LLMChatMessage {
role: "user".to_string(),
content: Some(user_prompt.to_string()),
tool_calls: None,
}];
let max_iterations = 10;
let mut iteration = 0;
let mut final_response = String::new();
loop {
iteration += 1;
if iteration > max_iterations {
self.chat_panel.add_message(ChatMessage::System(
"⚠️ Max iterations reached".to_string()
));
break;
}
// Call LLM with streaming
let mut stream = self.client.chat_stream(&messages, &self.opts, Some(&tools)).await?;
let mut response_content = String::new();
let mut tool_calls = None;
// Collect the streamed response
while let Some(chunk) = stream.try_next().await? {
if let Some(msg) = chunk.message {
if let Some(content) = msg.content {
response_content.push_str(&content);
// Stream chunks to UI - append to last assistant message
self.chat_panel.append_to_assistant(&content);
}
if let Some(calls) = msg.tool_calls {
tool_calls = Some(calls);
}
}
}
drop(stream);
// Save the response for final return
if !response_content.is_empty() {
final_response = response_content.clone();
}
// Check if LLM wants to call tools
if let Some(calls) = tool_calls {
// Add assistant message with tool calls to conversation
messages.push(LLMChatMessage {
role: "assistant".to_string(),
content: if response_content.is_empty() {
None
} else {
Some(response_content.clone())
},
tool_calls: Some(calls.clone()),
});
// Execute each tool call
for call in calls {
let tool_name = &call.function.name;
let arguments = &call.function.arguments;
// Show tool call in UI
self.chat_panel.add_message(ChatMessage::ToolCall {
name: tool_name.clone(),
args: arguments.to_string(),
});
self.stats.record_tool_call();
match execute_tool(tool_name, arguments, &self.perms).await {
Ok(result) => {
// Show success in UI
self.chat_panel.add_message(ChatMessage::ToolResult {
success: true,
output: result.clone(),
});
// Add tool result to conversation
messages.push(LLMChatMessage {
role: "tool".to_string(),
content: Some(result),
tool_calls: None,
});
}
Err(e) => {
let error_msg = format!("Error: {}", e);
// Show error in UI
self.chat_panel.add_message(ChatMessage::ToolResult {
success: false,
output: error_msg.clone(),
});
// Add error to conversation
messages.push(LLMChatMessage {
role: "tool".to_string(),
content: Some(error_msg),
tool_calls: None,
});
}
}
}
// Continue loop to get next response
continue;
}
// No tool calls, we're done
break;
}
Ok(final_response)
}
fn handle_command(&mut self, command: &str) -> Result<()> {
match command {
"/help" => {
self.chat_panel.add_message(ChatMessage::System(
"Available commands: /help, /status, /permissions, /cost, /history, /checkpoint, /checkpoints, /rewind, /clear, /theme, /themes, /exit".to_string(),
));
}
"/status" => {
let elapsed = self.stats.start_time.elapsed().unwrap_or_default();
self.chat_panel.add_message(ChatMessage::System(format!(
"Model: {} | Mode: {:?} | Messages: {} | Tools: {} | Uptime: {}",
self.opts.model,
self.perms.mode(),
self.stats.total_messages,
self.stats.total_tool_calls,
SessionStats::format_duration(elapsed)
)));
}
"/permissions" => {
self.chat_panel.add_message(ChatMessage::System(format!(
"Permission mode: {:?}",
self.perms.mode()
)));
}
"/cost" => {
self.chat_panel.add_message(ChatMessage::System(format!(
"Estimated tokens: ~{} | Total time: {} | Note: Ollama is free!",
self.stats.estimated_tokens,
SessionStats::format_duration(self.stats.total_duration)
)));
}
"/history" => {
let count = self.history.user_prompts.len();
self.chat_panel.add_message(ChatMessage::System(format!(
"Conversation has {} messages",
count
)));
}
"/checkpoint" => {
let checkpoint_id = format!(
"checkpoint-{}",
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
);
match self
.checkpoint_mgr
.save_checkpoint(checkpoint_id.clone(), self.stats.clone(), &self.history)
{
Ok(_) => {
self.chat_panel.add_message(ChatMessage::System(format!(
"💾 Checkpoint saved: {}",
checkpoint_id
)));
}
Err(e) => {
self.chat_panel.add_message(ChatMessage::System(format!(
"❌ Failed to save checkpoint: {}",
e
)));
}
}
}
"/checkpoints" => {
match self.checkpoint_mgr.list_checkpoints() {
Ok(checkpoints) => {
if checkpoints.is_empty() {
self.chat_panel
.add_message(ChatMessage::System("No checkpoints saved yet".to_string()));
} else {
self.chat_panel.add_message(ChatMessage::System(format!(
"Saved checkpoints: {}",
checkpoints.join(", ")
)));
}
}
Err(e) => {
self.chat_panel.add_message(ChatMessage::System(format!(
"❌ Failed to list checkpoints: {}",
e
)));
}
}
}
"/clear" => {
self.chat_panel.clear();
self.history.clear();
self.stats = SessionStats::new();
self.chat_panel
.add_message(ChatMessage::System("🗑️ Session cleared!".to_string()));
}
"/themes" => {
self.chat_panel.add_message(ChatMessage::System(
"Available themes:".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
" • tokyo-night - Modern and vibrant (default)".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
" • dracula - Classic dark theme".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
" • catppuccin - Warm and cozy".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
" • nord - Minimal and clean".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
" • synthwave - Vibrant retro".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
" • rose-pine - Elegant and muted".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
" • midnight-ocean - Deep and serene".to_string()
));
self.chat_panel.add_message(ChatMessage::System(
"Use '/theme <name>' to switch themes".to_string()
));
}
"/exit" => {
self.running = false;
}
cmd if cmd.starts_with("/theme ") => {
let theme_name = cmd.strip_prefix("/theme ").unwrap().trim();
let new_theme = match theme_name {
"tokyo-night" => Some(Theme::tokyo_night()),
"dracula" => Some(Theme::dracula()),
"catppuccin" => Some(Theme::catppuccin()),
"nord" => Some(Theme::nord()),
"synthwave" => Some(Theme::synthwave()),
"rose-pine" => Some(Theme::rose_pine()),
"midnight-ocean" => Some(Theme::midnight_ocean()),
_ => None,
};
if let Some(theme) = new_theme {
self.set_theme(theme);
self.chat_panel.add_message(ChatMessage::System(
format!("🎨 Theme changed to: {}", theme_name)
));
} else {
self.chat_panel.add_message(ChatMessage::System(
format!("❌ Unknown theme: {}. Use '/themes' to see available themes.", theme_name)
));
}
}
cmd if cmd.starts_with("/rewind ") => {
let checkpoint_id = cmd.strip_prefix("/rewind ").unwrap().trim();
match self.checkpoint_mgr.rewind_to(checkpoint_id) {
Ok(restored_files) => {
self.chat_panel.add_message(ChatMessage::System(format!(
"⏪ Rewound to checkpoint: {} ({} files restored)",
checkpoint_id,
restored_files.len()
)));
}
Err(e) => {
self.chat_panel.add_message(ChatMessage::System(format!(
"❌ Failed to rewind: {}",
e
)));
}
}
}
_ => {
self.chat_panel.add_message(ChatMessage::System(format!(
"❌ Unknown command: {}",
command
)));
}
}
Ok(())
}
}

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

View File

@@ -0,0 +1,38 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde_json::Value;
/// Application events that drive the TUI
#[derive(Debug, Clone)]
pub enum AppEvent {
/// User input from keyboard
Input(KeyEvent),
/// User submitted a message
UserMessage(String),
/// LLM response chunk
LlmChunk(String),
/// Tool call started
ToolCall { name: String, args: Value },
/// Tool execution result
ToolResult { success: bool, output: String },
/// Permission request from agent
PermissionRequest {
tool: String,
context: Option<String>,
},
/// Session statistics updated
StatusUpdate(agent_core::SessionStats),
/// Terminal was resized
Resize { width: u16, height: u16 },
/// Application should quit
Quit,
}
/// Process keyboard input into app events
pub fn handle_key_event(key: KeyEvent) -> Option<AppEvent> {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(AppEvent::Quit)
}
_ => Some(AppEvent::Input(key)),
}
}

View File

@@ -0,0 +1,49 @@
use ratatui::layout::{Constraint, Direction, Layout, Rect};
/// Calculate layout areas for the TUI
pub struct AppLayout {
pub chat_area: Rect,
pub input_area: Rect,
pub status_area: Rect,
}
impl AppLayout {
/// Calculate layout from terminal size
pub fn calculate(area: Rect) -> Self {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), // Chat area (grows)
Constraint::Length(3), // Input area (fixed height)
Constraint::Length(1), // Status bar (fixed height)
])
.split(area);
Self {
chat_area: chunks[0],
input_area: chunks[1],
status_area: chunks[2],
}
}
/// Center a popup in the given area
pub fn center_popup(area: Rect, width: u16, height: u16) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length((area.height.saturating_sub(height)) / 2),
Constraint::Length(height),
Constraint::Length((area.height.saturating_sub(height)) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length((area.width.saturating_sub(width)) / 2),
Constraint::Length(width),
Constraint::Length((area.width.saturating_sub(width)) / 2),
])
.split(popup_layout[1])[1]
}
}

21
crates/app/ui/src/lib.rs Normal file
View File

@@ -0,0 +1,21 @@
pub mod app;
pub mod components;
pub mod events;
pub mod layout;
pub mod theme;
pub use app::TuiApp;
pub use events::AppEvent;
use color_eyre::eyre::Result;
/// Run the TUI application
pub async fn run(
client: llm_ollama::OllamaClient,
opts: llm_ollama::OllamaOptions,
perms: permissions::PermissionManager,
settings: config_agent::Settings,
) -> Result<()> {
let mut app = TuiApp::new(client, opts, perms, settings)?;
app.run().await
}

257
crates/app/ui/src/theme.rs Normal file
View File

@@ -0,0 +1,257 @@
use ratatui::style::{Color, Modifier, Style};
/// Modern color palette inspired by contemporary design systems
#[derive(Debug, Clone)]
pub struct ColorPalette {
pub primary: Color,
pub secondary: Color,
pub accent: Color,
pub success: Color,
pub warning: Color,
pub error: Color,
pub info: Color,
pub bg: Color,
pub fg: Color,
pub fg_dim: Color,
pub border: Color,
pub highlight: Color,
}
impl ColorPalette {
/// Tokyo Night inspired palette - vibrant and modern
pub fn tokyo_night() -> Self {
Self {
primary: Color::Rgb(122, 162, 247), // Bright blue
secondary: Color::Rgb(187, 154, 247), // Purple
accent: Color::Rgb(255, 158, 100), // Orange
success: Color::Rgb(158, 206, 106), // Green
warning: Color::Rgb(224, 175, 104), // Yellow
error: Color::Rgb(247, 118, 142), // Pink/Red
info: Color::Rgb(125, 207, 255), // Cyan
bg: Color::Rgb(26, 27, 38), // Dark bg
fg: Color::Rgb(192, 202, 245), // Light text
fg_dim: Color::Rgb(86, 95, 137), // Dimmed text
border: Color::Rgb(77, 124, 254), // Blue border
highlight: Color::Rgb(56, 62, 90), // Selection bg
}
}
/// Dracula inspired palette - classic and elegant
pub fn dracula() -> Self {
Self {
primary: Color::Rgb(139, 233, 253), // Cyan
secondary: Color::Rgb(189, 147, 249), // Purple
accent: Color::Rgb(255, 121, 198), // Pink
success: Color::Rgb(80, 250, 123), // Green
warning: Color::Rgb(241, 250, 140), // Yellow
error: Color::Rgb(255, 85, 85), // Red
info: Color::Rgb(139, 233, 253), // Cyan
bg: Color::Rgb(40, 42, 54), // Dark bg
fg: Color::Rgb(248, 248, 242), // Light text
fg_dim: Color::Rgb(98, 114, 164), // Comment
border: Color::Rgb(98, 114, 164), // Border
highlight: Color::Rgb(68, 71, 90), // Selection
}
}
/// Catppuccin Mocha - warm and cozy
pub fn catppuccin() -> Self {
Self {
primary: Color::Rgb(137, 180, 250), // Blue
secondary: Color::Rgb(203, 166, 247), // Mauve
accent: Color::Rgb(245, 194, 231), // Pink
success: Color::Rgb(166, 227, 161), // Green
warning: Color::Rgb(249, 226, 175), // Yellow
error: Color::Rgb(243, 139, 168), // Red
info: Color::Rgb(148, 226, 213), // Teal
bg: Color::Rgb(30, 30, 46), // Base
fg: Color::Rgb(205, 214, 244), // Text
fg_dim: Color::Rgb(108, 112, 134), // Overlay
border: Color::Rgb(137, 180, 250), // Blue
highlight: Color::Rgb(49, 50, 68), // Surface
}
}
/// Nord - minimal and clean
pub fn nord() -> Self {
Self {
primary: Color::Rgb(136, 192, 208), // Frost cyan
secondary: Color::Rgb(129, 161, 193), // Frost blue
accent: Color::Rgb(180, 142, 173), // Aurora purple
success: Color::Rgb(163, 190, 140), // Aurora green
warning: Color::Rgb(235, 203, 139), // Aurora yellow
error: Color::Rgb(191, 97, 106), // Aurora red
info: Color::Rgb(136, 192, 208), // Frost cyan
bg: Color::Rgb(46, 52, 64), // Polar night
fg: Color::Rgb(236, 239, 244), // Snow storm
fg_dim: Color::Rgb(76, 86, 106), // Polar night light
border: Color::Rgb(129, 161, 193), // Frost
highlight: Color::Rgb(59, 66, 82), // Selection
}
}
/// Synthwave - vibrant and retro
pub fn synthwave() -> Self {
Self {
primary: Color::Rgb(255, 0, 128), // Hot pink
secondary: Color::Rgb(0, 229, 255), // Cyan
accent: Color::Rgb(255, 128, 0), // Orange
success: Color::Rgb(0, 255, 157), // Neon green
warning: Color::Rgb(255, 215, 0), // Gold
error: Color::Rgb(255, 64, 64), // Neon red
info: Color::Rgb(0, 229, 255), // Cyan
bg: Color::Rgb(20, 16, 32), // Dark purple
fg: Color::Rgb(242, 233, 255), // Light purple
fg_dim: Color::Rgb(127, 90, 180), // Mid purple
border: Color::Rgb(255, 0, 128), // Hot pink
highlight: Color::Rgb(72, 12, 168), // Deep purple
}
}
/// Rose Pine - elegant and muted
pub fn rose_pine() -> Self {
Self {
primary: Color::Rgb(156, 207, 216), // Foam
secondary: Color::Rgb(235, 188, 186), // Rose
accent: Color::Rgb(234, 154, 151), // Love
success: Color::Rgb(49, 116, 143), // Pine
warning: Color::Rgb(246, 193, 119), // Gold
error: Color::Rgb(235, 111, 146), // Love (darker)
info: Color::Rgb(156, 207, 216), // Foam
bg: Color::Rgb(25, 23, 36), // Base
fg: Color::Rgb(224, 222, 244), // Text
fg_dim: Color::Rgb(110, 106, 134), // Muted
border: Color::Rgb(156, 207, 216), // Foam
highlight: Color::Rgb(42, 39, 63), // Highlight
}
}
/// Midnight Ocean - deep and serene
pub fn midnight_ocean() -> Self {
Self {
primary: Color::Rgb(102, 217, 239), // Bright cyan
secondary: Color::Rgb(130, 170, 255), // Periwinkle
accent: Color::Rgb(199, 146, 234), // Purple
success: Color::Rgb(163, 190, 140), // Sea green
warning: Color::Rgb(229, 200, 144), // Sandy yellow
error: Color::Rgb(236, 95, 103), // Coral red
info: Color::Rgb(102, 217, 239), // Bright cyan
bg: Color::Rgb(1, 22, 39), // Deep ocean
fg: Color::Rgb(201, 211, 235), // Light blue-white
fg_dim: Color::Rgb(71, 103, 145), // Muted blue
border: Color::Rgb(102, 217, 239), // Bright cyan
highlight: Color::Rgb(13, 43, 69), // Deep blue
}
}
}
/// Theme configuration for the TUI
#[derive(Debug, Clone)]
pub struct Theme {
pub palette: ColorPalette,
pub user_message: Style,
pub assistant_message: Style,
pub tool_call: Style,
pub tool_result_success: Style,
pub tool_result_error: Style,
pub status_bar: Style,
pub status_bar_highlight: Style,
pub input_box: Style,
pub input_box_active: Style,
pub popup_border: Style,
pub popup_bg: Style,
pub popup_title: Style,
pub selected: Style,
pub border: Style,
pub border_active: Style,
}
impl Theme {
/// Create theme from color palette
pub fn from_palette(palette: ColorPalette) -> Self {
Self {
user_message: Style::default()
.fg(palette.primary)
.add_modifier(Modifier::BOLD),
assistant_message: Style::default().fg(palette.fg),
tool_call: Style::default()
.fg(palette.warning)
.add_modifier(Modifier::ITALIC),
tool_result_success: Style::default()
.fg(palette.success)
.add_modifier(Modifier::BOLD),
tool_result_error: Style::default()
.fg(palette.error)
.add_modifier(Modifier::BOLD),
status_bar: Style::default()
.fg(palette.bg)
.bg(palette.primary)
.add_modifier(Modifier::BOLD),
status_bar_highlight: Style::default()
.fg(palette.bg)
.bg(palette.accent)
.add_modifier(Modifier::BOLD),
input_box: Style::default().fg(palette.fg),
input_box_active: Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
popup_border: Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
popup_bg: Style::default().bg(palette.highlight),
popup_title: Style::default()
.fg(palette.accent)
.add_modifier(Modifier::BOLD),
selected: Style::default()
.fg(palette.bg)
.bg(palette.accent)
.add_modifier(Modifier::BOLD),
border: Style::default().fg(palette.border),
border_active: Style::default()
.fg(palette.primary)
.add_modifier(Modifier::BOLD),
palette,
}
}
/// Tokyo Night theme (default) - modern and vibrant
pub fn tokyo_night() -> Self {
Self::from_palette(ColorPalette::tokyo_night())
}
/// Dracula theme - classic dark theme
pub fn dracula() -> Self {
Self::from_palette(ColorPalette::dracula())
}
/// Catppuccin Mocha - warm and cozy
pub fn catppuccin() -> Self {
Self::from_palette(ColorPalette::catppuccin())
}
/// Nord theme - minimal and clean
pub fn nord() -> Self {
Self::from_palette(ColorPalette::nord())
}
/// Synthwave theme - vibrant retro
pub fn synthwave() -> Self {
Self::from_palette(ColorPalette::synthwave())
}
/// Rose Pine theme - elegant and muted
pub fn rose_pine() -> Self {
Self::from_palette(ColorPalette::rose_pine())
}
/// Midnight Ocean theme - deep and serene
pub fn midnight_ocean() -> Self {
Self::from_palette(ColorPalette::midnight_ocean())
}
}
impl Default for Theme {
fn default() -> Self {
Self::tokyo_night()
}
}