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:
@@ -1,6 +1,7 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/app/cli",
|
||||
"crates/app/ui",
|
||||
"crates/core/agent",
|
||||
"crates/llm/ollama",
|
||||
"crates/platform/config",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
23
crates/app/ui/Cargo.toml
Normal 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
561
crates/app/ui/src/app.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
191
crates/app/ui/src/components/chat_panel.rs
Normal file
191
crates/app/ui/src/components/chat_panel.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
9
crates/app/ui/src/components/mod.rs
Normal file
9
crates/app/ui/src/components/mod.rs
Normal 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;
|
||||
196
crates/app/ui/src/components/permission_popup.rs
Normal file
196
crates/app/ui/src/components/permission_popup.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
109
crates/app/ui/src/components/status_bar.rs
Normal file
109
crates/app/ui/src/components/status_bar.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
38
crates/app/ui/src/events.rs
Normal file
38
crates/app/ui/src/events.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
49
crates/app/ui/src/layout.rs
Normal file
49
crates/app/ui/src/layout.rs
Normal 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
21
crates/app/ui/src/lib.rs
Normal 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
257
crates/app/ui/src/theme.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user