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]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"crates/app/cli",
|
"crates/app/cli",
|
||||||
|
"crates/app/ui",
|
||||||
"crates/core/agent",
|
"crates/core/agent",
|
||||||
"crates/llm/ollama",
|
"crates/llm/ollama",
|
||||||
"crates/platform/config",
|
"crates/platform/config",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ tools-slash = { path = "../../tools/slash" }
|
|||||||
config-agent = { package = "config-agent", path = "../../platform/config" }
|
config-agent = { package = "config-agent", path = "../../platform/config" }
|
||||||
permissions = { path = "../../platform/permissions" }
|
permissions = { path = "../../platform/permissions" }
|
||||||
hooks = { path = "../../platform/hooks" }
|
hooks = { path = "../../platform/hooks" }
|
||||||
|
ui = { path = "../ui" }
|
||||||
|
atty = "0.2"
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -149,6 +149,9 @@ struct Args {
|
|||||||
/// Output format (text, json, stream-json)
|
/// Output format (text, json, stream-json)
|
||||||
#[arg(long, value_enum, default_value = "text")]
|
#[arg(long, value_enum, default_value = "text")]
|
||||||
output_format: OutputFormat,
|
output_format: OutputFormat,
|
||||||
|
/// Disable TUI and use legacy text-based REPL
|
||||||
|
#[arg(long)]
|
||||||
|
no_tui: bool,
|
||||||
#[arg()]
|
#[arg()]
|
||||||
prompt: Vec<String>,
|
prompt: Vec<String>,
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -434,15 +437,15 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let model = args.model.unwrap_or(settings.model);
|
let model = args.model.unwrap_or(settings.model.clone());
|
||||||
let api_key = args.api_key.or(settings.api_key);
|
let api_key = args.api_key.or(settings.api_key.clone());
|
||||||
|
|
||||||
// Use Ollama Cloud when model has "-cloud" suffix AND API key is set
|
// 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 use_cloud = model.ends_with("-cloud") && api_key.is_some();
|
||||||
let client = if use_cloud {
|
let client = if use_cloud {
|
||||||
OllamaClient::with_cloud().with_api_key(api_key.unwrap())
|
OllamaClient::with_cloud().with_api_key(api_key.unwrap())
|
||||||
} else {
|
} 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);
|
let mut client = OllamaClient::new(base_url);
|
||||||
if let Some(key) = api_key {
|
if let Some(key) = api_key {
|
||||||
client = client.with_api_key(key);
|
client = client.with_api_key(key);
|
||||||
@@ -456,6 +459,13 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
// Check if interactive mode (no prompt provided)
|
// Check if interactive mode (no prompt provided)
|
||||||
if args.prompt.is_empty() {
|
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!("🤖 Owlen Interactive Mode");
|
||||||
println!("Model: {}", opts.model);
|
println!("Model: {}", opts.model);
|
||||||
println!("Mode: {:?}", settings.mode);
|
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