feat(v2): complete multi-LLM providers, TUI redesign, and advanced agent features
Multi-LLM Provider Support: - Add llm-core crate with LlmProvider trait abstraction - Implement Anthropic Claude API client with streaming - Implement OpenAI API client with streaming - Add token counting with SimpleTokenCounter and ClaudeTokenCounter - Add retry logic with exponential backoff and jitter Borderless TUI Redesign: - Rewrite theme system with terminal capability detection (Full/Unicode256/Basic) - Add provider tabs component with keybind switching [1]/[2]/[3] - Implement vim-modal input (Normal/Insert/Visual/Command modes) - Redesign chat panel with timestamps and streaming indicators - Add multi-provider status bar with cost tracking - Add Nerd Font icons with graceful ASCII fallbacks - Add syntax highlighting (syntect) and markdown rendering (pulldown-cmark) Advanced Agent Features: - Add system prompt builder with configurable components - Enhance subagent orchestration with parallel execution - Add git integration module for safe command detection - Add streaming tool results via channels - Expand tool set: AskUserQuestion, TodoWrite, LS, MultiEdit, BashOutput, KillShell - Add WebSearch with provider abstraction Plugin System Enhancement: - Add full agent definition parsing from YAML frontmatter - Add skill system with progressive disclosure - Wire plugin hooks into HookManager 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
color-eyre = "0.6"
|
||||
agent-core = { path = "../../core/agent" }
|
||||
llm-core = { path = "../../llm/core" }
|
||||
llm-ollama = { path = "../../llm/ollama" }
|
||||
tools-fs = { path = "../../tools/fs" }
|
||||
tools-bash = { path = "../../tools/bash" }
|
||||
@@ -19,6 +20,7 @@ tools-slash = { path = "../../tools/slash" }
|
||||
config-agent = { package = "config-agent", path = "../../platform/config" }
|
||||
permissions = { path = "../../platform/permissions" }
|
||||
hooks = { path = "../../platform/hooks" }
|
||||
plugins = { path = "../../platform/plugins" }
|
||||
ui = { path = "../ui" }
|
||||
atty = "0.2"
|
||||
futures-util = "0.3.31"
|
||||
|
||||
@@ -2,8 +2,10 @@ use clap::{Parser, ValueEnum};
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use config_agent::load_settings;
|
||||
use hooks::{HookEvent, HookManager, HookResult};
|
||||
use llm_ollama::{OllamaClient, OllamaOptions};
|
||||
use llm_core::ChatOptions;
|
||||
use llm_ollama::OllamaClient;
|
||||
use permissions::{PermissionDecision, Tool};
|
||||
use plugins::PluginManager;
|
||||
use serde::Serialize;
|
||||
use std::io::Write;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -48,6 +50,51 @@ struct StreamEvent {
|
||||
stats: Option<Stats>,
|
||||
}
|
||||
|
||||
/// Application context shared across the session
|
||||
pub struct AppContext {
|
||||
pub plugin_manager: PluginManager,
|
||||
pub config: config_agent::Settings,
|
||||
}
|
||||
|
||||
impl AppContext {
|
||||
pub fn new() -> Result<Self> {
|
||||
let config = load_settings(None).unwrap_or_default();
|
||||
|
||||
let mut plugin_manager = PluginManager::new();
|
||||
// Non-fatal: just log warnings, don't fail startup
|
||||
if let Err(e) = plugin_manager.load_all() {
|
||||
eprintln!("Warning: Failed to load some plugins: {}", e);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
plugin_manager,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// Print loaded plugins and available commands
|
||||
pub fn print_plugin_info(&self) {
|
||||
let plugins = self.plugin_manager.plugins();
|
||||
if !plugins.is_empty() {
|
||||
println!("\nLoaded {} plugin(s):", plugins.len());
|
||||
for plugin in plugins {
|
||||
println!(" - {} v{}", plugin.manifest.name, plugin.manifest.version);
|
||||
if let Some(desc) = &plugin.manifest.description {
|
||||
println!(" {}", desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let commands = self.plugin_manager.all_commands();
|
||||
if !commands.is_empty() {
|
||||
println!("\nAvailable plugin commands:");
|
||||
for (name, _path) in &commands {
|
||||
println!(" /{}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -162,7 +209,10 @@ struct Args {
|
||||
async fn main() -> Result<()> {
|
||||
color_eyre::install()?;
|
||||
let args = Args::parse();
|
||||
let mut settings = load_settings(None).unwrap_or_default();
|
||||
|
||||
// Initialize application context with plugins
|
||||
let app_context = AppContext::new()?;
|
||||
let mut settings = app_context.config.clone();
|
||||
|
||||
// Override mode if specified via CLI
|
||||
if let Some(mode) = args.mode {
|
||||
@@ -173,7 +223,16 @@ async fn main() -> Result<()> {
|
||||
let perms = settings.create_permission_manager();
|
||||
|
||||
// Create hook manager
|
||||
let hook_mgr = HookManager::new(".");
|
||||
let mut hook_mgr = HookManager::new(".");
|
||||
|
||||
// Register plugin hooks
|
||||
for plugin in app_context.plugin_manager.plugins() {
|
||||
if let Ok(Some(hooks_config)) = plugin.load_hooks_config() {
|
||||
for (event, command, pattern, timeout) in plugin.register_hooks_with_manager(&hooks_config) {
|
||||
hook_mgr.register_hook(event, command, pattern, timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate session ID
|
||||
let session_id = generate_session_id();
|
||||
@@ -397,19 +456,20 @@ async fn main() -> Result<()> {
|
||||
HookResult::Allow => {}
|
||||
}
|
||||
|
||||
// Look for command file in .owlen/commands/
|
||||
let command_path = format!(".owlen/commands/{}.md", command_name);
|
||||
// Look for command file in .owlen/commands/ first
|
||||
let local_command_path = format!(".owlen/commands/{}.md", command_name);
|
||||
|
||||
// Read the command file
|
||||
let content = match tools_fs::read_file(&command_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Err(eyre!(
|
||||
"Slash command '{}' not found at {}",
|
||||
command_name,
|
||||
command_path
|
||||
));
|
||||
}
|
||||
// Try local commands first, then plugin commands
|
||||
let content = if let Ok(c) = tools_fs::read_file(&local_command_path) {
|
||||
c
|
||||
} else if let Some(plugin_path) = app_context.plugin_manager.all_commands().get(&command_name) {
|
||||
// Found in plugins
|
||||
tools_fs::read_file(&plugin_path.to_string_lossy())?
|
||||
} else {
|
||||
return Err(eyre!(
|
||||
"Slash command '{}' not found in .owlen/commands/ or plugins",
|
||||
command_name
|
||||
));
|
||||
};
|
||||
|
||||
// Parse with arguments
|
||||
@@ -452,16 +512,15 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
client
|
||||
};
|
||||
let opts = OllamaOptions {
|
||||
model,
|
||||
stream: true,
|
||||
};
|
||||
let opts = ChatOptions::new(model);
|
||||
|
||||
// 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
|
||||
// Note: For now, TUI doesn't use plugin manager directly
|
||||
// In the future, we'll integrate plugin commands into TUI
|
||||
return ui::run(client, opts, perms, settings).await;
|
||||
}
|
||||
|
||||
@@ -469,6 +528,13 @@ async fn main() -> Result<()> {
|
||||
println!("🤖 Owlen Interactive Mode");
|
||||
println!("Model: {}", opts.model);
|
||||
println!("Mode: {:?}", settings.mode);
|
||||
|
||||
// Show loaded plugins
|
||||
let plugins = app_context.plugin_manager.plugins();
|
||||
if !plugins.is_empty() {
|
||||
println!("Plugins: {} loaded", plugins.len());
|
||||
}
|
||||
|
||||
println!("Type your message or /help for commands. Press Ctrl+C to exit.\n");
|
||||
|
||||
use std::io::{stdin, BufRead};
|
||||
@@ -504,7 +570,17 @@ async fn main() -> Result<()> {
|
||||
println!(" /checkpoints - List all saved checkpoints");
|
||||
println!(" /rewind <id> - Restore session from checkpoint");
|
||||
println!(" /clear - Clear conversation history");
|
||||
println!(" /plugins - Show loaded plugins and commands");
|
||||
println!(" /exit - Exit interactive mode");
|
||||
|
||||
// Show plugin commands if any are loaded
|
||||
let plugin_commands = app_context.plugin_manager.all_commands();
|
||||
if !plugin_commands.is_empty() {
|
||||
println!("\n📦 Plugin Commands:");
|
||||
for (name, _path) in &plugin_commands {
|
||||
println!(" /{}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
"/status" => {
|
||||
println!("\n📊 Session Status:");
|
||||
@@ -615,6 +691,41 @@ async fn main() -> Result<()> {
|
||||
stats = agent_core::SessionStats::new();
|
||||
println!("\n🗑️ Session history cleared!");
|
||||
}
|
||||
"/plugins" => {
|
||||
let plugins = app_context.plugin_manager.plugins();
|
||||
if plugins.is_empty() {
|
||||
println!("\n📦 No plugins loaded");
|
||||
println!(" Place plugins in:");
|
||||
println!(" - ~/.config/owlen/plugins (user plugins)");
|
||||
println!(" - .owlen/plugins (project plugins)");
|
||||
} else {
|
||||
println!("\n📦 Loaded Plugins:");
|
||||
for plugin in plugins {
|
||||
println!("\n {} v{}", plugin.manifest.name, plugin.manifest.version);
|
||||
if let Some(desc) = &plugin.manifest.description {
|
||||
println!(" {}", desc);
|
||||
}
|
||||
if let Some(author) = &plugin.manifest.author {
|
||||
println!(" Author: {}", author);
|
||||
}
|
||||
|
||||
let commands = plugin.all_command_names();
|
||||
if !commands.is_empty() {
|
||||
println!(" Commands: {}", commands.join(", "));
|
||||
}
|
||||
|
||||
let agents = plugin.all_agent_names();
|
||||
if !agents.is_empty() {
|
||||
println!(" Agents: {}", agents.join(", "));
|
||||
}
|
||||
|
||||
let skills = plugin.all_skill_names();
|
||||
if !skills.is_empty() {
|
||||
println!(" Skills: {}", skills.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"/exit" => {
|
||||
println!("\n👋 Goodbye!");
|
||||
break;
|
||||
@@ -656,7 +767,8 @@ async fn main() -> Result<()> {
|
||||
history.add_user_message(input.to_string());
|
||||
let start = SystemTime::now();
|
||||
|
||||
match agent_core::run_agent_loop(&client, input, &opts, &perms).await {
|
||||
let ctx = agent_core::ToolContext::new();
|
||||
match agent_core::run_agent_loop(&client, input, &opts, &perms, &ctx).await {
|
||||
Ok(response) => {
|
||||
println!("\n{}", response);
|
||||
history.add_assistant_message(response.clone());
|
||||
@@ -683,15 +795,16 @@ async fn main() -> Result<()> {
|
||||
let start_time = SystemTime::now();
|
||||
|
||||
// Handle different output formats
|
||||
let ctx = agent_core::ToolContext::new();
|
||||
match output_format {
|
||||
OutputFormat::Text => {
|
||||
// Text format: Use agent orchestrator with tool calling
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms).await?;
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
|
||||
println!("{}", response);
|
||||
}
|
||||
OutputFormat::Json => {
|
||||
// JSON format: Use agent loop and output as JSON
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms).await?;
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
|
||||
|
||||
let duration_ms = start_time.elapsed().unwrap().as_millis() as u64;
|
||||
let estimated_tokens = ((prompt.len() + response.len()) / 4) as u64;
|
||||
@@ -724,7 +837,7 @@ async fn main() -> Result<()> {
|
||||
};
|
||||
println!("{}", serde_json::to_string(&session_start)?);
|
||||
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms).await?;
|
||||
let response = agent_core::run_agent_loop(&client, &prompt, &opts, &perms, &ctx).await?;
|
||||
|
||||
let chunk_event = StreamEvent {
|
||||
event_type: "chunk".to_string(),
|
||||
|
||||
@@ -5,12 +5,6 @@ use predicates::prelude::PredicateBooleanExt;
|
||||
#[tokio::test]
|
||||
async fn headless_streams_ndjson() {
|
||||
let server = MockServer::start_async().await;
|
||||
// Mock /api/chat with NDJSON lines
|
||||
let body = serde_json::json!({
|
||||
"model": "qwen2.5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let response = concat!(
|
||||
r#"{"message":{"role":"assistant","content":"Hel"}}"#,"\n",
|
||||
@@ -18,10 +12,11 @@ async fn headless_streams_ndjson() {
|
||||
r#"{"done":true}"#,"\n",
|
||||
);
|
||||
|
||||
// The CLI includes tools in the request, so we need to match any request to /api/chat
|
||||
// instead of matching exact body (which includes tool definitions)
|
||||
let _m = server.mock(|when, then| {
|
||||
when.method(POST)
|
||||
.path("/api/chat")
|
||||
.json_body(body.clone());
|
||||
.path("/api/chat");
|
||||
then.status(200)
|
||||
.header("content-type", "application/x-ndjson")
|
||||
.body(response);
|
||||
|
||||
@@ -15,9 +15,12 @@ serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
unicode-width = "0.2"
|
||||
textwrap = "0.16"
|
||||
syntect = { version = "5.0", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] }
|
||||
pulldown-cmark = "0.11"
|
||||
|
||||
# Internal dependencies
|
||||
agent-core = { path = "../../core/agent" }
|
||||
permissions = { path = "../../platform/permissions" }
|
||||
llm-core = { path = "../../llm/core" }
|
||||
llm-ollama = { path = "../../llm/ollama" }
|
||||
config-agent = { path = "../../platform/config" }
|
||||
|
||||
@@ -4,20 +4,30 @@ use crate::{
|
||||
layout::AppLayout,
|
||||
theme::Theme,
|
||||
};
|
||||
use agent_core::{CheckpointManager, SessionHistory, SessionStats, execute_tool, get_tool_definitions};
|
||||
use agent_core::{CheckpointManager, SessionHistory, SessionStats, ToolContext, execute_tool, get_tool_definitions};
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::{
|
||||
event::{Event, EventStream},
|
||||
event::{Event, EventStream, EnableMouseCapture, DisableMouseCapture},
|
||||
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 futures::StreamExt;
|
||||
use llm_core::{ChatMessage as LLMChatMessage, ChatOptions};
|
||||
use llm_ollama::OllamaClient;
|
||||
use permissions::{Action, PermissionDecision, PermissionManager, Tool as PermTool};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use serde_json::Value;
|
||||
use std::{io::stdout, path::PathBuf, time::SystemTime};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Holds information about a pending tool execution
|
||||
struct PendingToolCall {
|
||||
tool_name: String,
|
||||
arguments: Value,
|
||||
perm_tool: PermTool,
|
||||
context: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TuiApp {
|
||||
// UI components
|
||||
chat_panel: ChatPanel,
|
||||
@@ -33,20 +43,23 @@ pub struct TuiApp {
|
||||
|
||||
// System state
|
||||
client: OllamaClient,
|
||||
opts: OllamaOptions,
|
||||
opts: ChatOptions,
|
||||
perms: PermissionManager,
|
||||
ctx: ToolContext,
|
||||
#[allow(dead_code)]
|
||||
settings: config_agent::Settings,
|
||||
|
||||
// Runtime state
|
||||
running: bool,
|
||||
waiting_for_llm: bool,
|
||||
pending_tool: Option<PendingToolCall>,
|
||||
permission_tx: Option<tokio::sync::oneshot::Sender<bool>>,
|
||||
}
|
||||
|
||||
impl TuiApp {
|
||||
pub fn new(
|
||||
client: OllamaClient,
|
||||
opts: OllamaOptions,
|
||||
opts: ChatOptions,
|
||||
perms: PermissionManager,
|
||||
settings: config_agent::Settings,
|
||||
) -> Result<Self> {
|
||||
@@ -65,9 +78,12 @@ impl TuiApp {
|
||||
client,
|
||||
opts,
|
||||
perms,
|
||||
ctx: ToolContext::new(),
|
||||
settings,
|
||||
running: true,
|
||||
waiting_for_llm: false,
|
||||
pending_tool: None,
|
||||
permission_tx: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,7 +97,9 @@ impl TuiApp {
|
||||
pub async fn run(&mut self) -> Result<()> {
|
||||
// Setup terminal
|
||||
enable_raw_mode()?;
|
||||
stdout().execute(EnterAlternateScreen)?;
|
||||
stdout()
|
||||
.execute(EnterAlternateScreen)?
|
||||
.execute(EnableMouseCapture)?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
@@ -95,15 +113,31 @@ impl TuiApp {
|
||||
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);
|
||||
match event {
|
||||
Ok(Event::Key(key)) => {
|
||||
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,
|
||||
});
|
||||
Ok(Event::Mouse(mouse)) => {
|
||||
use crossterm::event::MouseEventKind;
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollUp => {
|
||||
let _ = tx_clone.send(AppEvent::ScrollUp);
|
||||
}
|
||||
MouseEventKind::ScrollDown => {
|
||||
let _ = tx_clone.send(AppEvent::ScrollDown);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::Resize(w, h)) => {
|
||||
let _ = tx_clone.send(AppEvent::Resize {
|
||||
width: w,
|
||||
height: h,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -135,6 +169,9 @@ impl TuiApp {
|
||||
let size = frame.area();
|
||||
let layout = AppLayout::calculate(size);
|
||||
|
||||
// Update scroll position before rendering
|
||||
self.chat_panel.update_scroll(layout.chat_area);
|
||||
|
||||
// Render main components
|
||||
self.chat_panel.render(frame, layout.chat_area);
|
||||
self.input_box.render(frame, layout.input_area);
|
||||
@@ -157,7 +194,9 @@ impl TuiApp {
|
||||
|
||||
// Cleanup terminal
|
||||
disable_raw_mode()?;
|
||||
stdout().execute(LeaveAlternateScreen)?;
|
||||
stdout()
|
||||
.execute(LeaveAlternateScreen)?
|
||||
.execute(DisableMouseCapture)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -172,19 +211,98 @@ impl TuiApp {
|
||||
// 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)
|
||||
));
|
||||
use crate::components::PermissionOption;
|
||||
|
||||
match option {
|
||||
PermissionOption::AllowOnce => {
|
||||
self.chat_panel.add_message(ChatMessage::System(
|
||||
"✓ Permission granted once".to_string()
|
||||
));
|
||||
if let Some(tx) = self.permission_tx.take() {
|
||||
let _ = tx.send(true);
|
||||
}
|
||||
}
|
||||
PermissionOption::AlwaysAllow => {
|
||||
// Add rule to permission manager
|
||||
if let Some(pending) = &self.pending_tool {
|
||||
self.perms.add_rule(
|
||||
pending.perm_tool,
|
||||
pending.context.clone(),
|
||||
Action::Allow,
|
||||
);
|
||||
self.chat_panel.add_message(ChatMessage::System(
|
||||
format!("✓ Always allowed: {}", pending.tool_name)
|
||||
));
|
||||
}
|
||||
if let Some(tx) = self.permission_tx.take() {
|
||||
let _ = tx.send(true);
|
||||
}
|
||||
}
|
||||
PermissionOption::Deny => {
|
||||
self.chat_panel.add_message(ChatMessage::System(
|
||||
"✗ Permission denied".to_string()
|
||||
));
|
||||
if let Some(tx) = self.permission_tx.take() {
|
||||
let _ = tx.send(false);
|
||||
}
|
||||
}
|
||||
PermissionOption::Explain => {
|
||||
// Show explanation
|
||||
if let Some(pending) = &self.pending_tool {
|
||||
let explanation = format!(
|
||||
"Tool '{}' requires permission. This operation will {}.",
|
||||
pending.tool_name,
|
||||
match pending.tool_name.as_str() {
|
||||
"read" => "read a file from disk",
|
||||
"write" => "write or overwrite a file",
|
||||
"edit" => "modify an existing file",
|
||||
"bash" => "execute a shell command",
|
||||
"grep" => "search for patterns in files",
|
||||
"glob" => "list files matching a pattern",
|
||||
_ => "perform an operation",
|
||||
}
|
||||
);
|
||||
self.chat_panel.add_message(ChatMessage::System(explanation));
|
||||
}
|
||||
// Don't close popup, let user choose again
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.permission_popup = None;
|
||||
self.pending_tool = None;
|
||||
}
|
||||
} else {
|
||||
// Handle input box
|
||||
if let Some(message) = self.input_box.handle_key(key) {
|
||||
self.handle_user_message(message, event_tx).await?;
|
||||
// Handle input box with vim-modal events
|
||||
use crate::components::InputEvent;
|
||||
if let Some(event) = self.input_box.handle_key(key) {
|
||||
match event {
|
||||
InputEvent::Message(message) => {
|
||||
self.handle_user_message(message, event_tx).await?;
|
||||
}
|
||||
InputEvent::Command(cmd) => {
|
||||
// Commands from command mode (without /)
|
||||
self.handle_command(&format!("/{}", cmd))?;
|
||||
}
|
||||
InputEvent::ModeChange(mode) => {
|
||||
self.status_bar.set_vim_mode(mode);
|
||||
}
|
||||
InputEvent::Cancel => {
|
||||
// Cancel current operation
|
||||
self.waiting_for_llm = false;
|
||||
}
|
||||
InputEvent::Expand => {
|
||||
// TODO: Expand to multiline input
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::ScrollUp => {
|
||||
self.chat_panel.scroll_up(3);
|
||||
}
|
||||
AppEvent::ScrollDown => {
|
||||
self.chat_panel.scroll_down(3);
|
||||
}
|
||||
AppEvent::UserMessage(message) => {
|
||||
self.chat_panel
|
||||
.add_message(ChatMessage::User(message.clone()));
|
||||
@@ -265,13 +383,101 @@ impl TuiApp {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Execute a tool with permission handling
|
||||
///
|
||||
/// This method checks permissions and either:
|
||||
/// - Executes the tool immediately if allowed
|
||||
/// - Returns an error if denied by policy
|
||||
/// - Shows a permission popup and waits for user decision if permission is needed
|
||||
///
|
||||
/// The async wait for user decision works correctly because:
|
||||
/// 1. The event loop continues running while we await the channel
|
||||
/// 2. Keyboard events are processed by the separate event listener task
|
||||
/// 3. When user responds to popup, the channel is signaled and we resume
|
||||
///
|
||||
/// Returns Ok(result) if allowed and executed, Err if denied or failed
|
||||
async fn execute_tool_with_permission(
|
||||
&mut self,
|
||||
tool_name: &str,
|
||||
arguments: &Value,
|
||||
) -> Result<String> {
|
||||
// Map tool name to permission tool enum
|
||||
let perm_tool = match tool_name {
|
||||
"read" => PermTool::Read,
|
||||
"write" => PermTool::Write,
|
||||
"edit" => PermTool::Edit,
|
||||
"bash" => PermTool::Bash,
|
||||
"grep" => PermTool::Grep,
|
||||
"glob" => PermTool::Glob,
|
||||
_ => PermTool::Read, // Default fallback
|
||||
};
|
||||
|
||||
// Extract context from arguments
|
||||
let context = match tool_name {
|
||||
"read" | "write" | "edit" => arguments.get("path").and_then(|v| v.as_str()).map(String::from),
|
||||
"bash" => arguments.get("command").and_then(|v| v.as_str()).map(String::from),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Check permission
|
||||
let decision = self.perms.check(perm_tool, context.as_deref());
|
||||
|
||||
match decision {
|
||||
PermissionDecision::Allow => {
|
||||
// Execute directly
|
||||
execute_tool(tool_name, arguments, &self.perms, &self.ctx).await
|
||||
}
|
||||
PermissionDecision::Deny => {
|
||||
Err(color_eyre::eyre::eyre!("Permission denied by policy"))
|
||||
}
|
||||
PermissionDecision::Ask => {
|
||||
// Create channel for response
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
self.permission_tx = Some(tx);
|
||||
|
||||
// Store pending tool info
|
||||
self.pending_tool = Some(PendingToolCall {
|
||||
tool_name: tool_name.to_string(),
|
||||
arguments: arguments.clone(),
|
||||
perm_tool,
|
||||
context: context.clone(),
|
||||
});
|
||||
|
||||
// Show permission popup
|
||||
self.permission_popup = Some(PermissionPopup::new(
|
||||
tool_name.to_string(),
|
||||
context,
|
||||
self.theme.clone(),
|
||||
));
|
||||
|
||||
// Wait for user decision (with timeout)
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
|
||||
Ok(Ok(true)) => {
|
||||
// Permission granted, execute tool
|
||||
execute_tool(tool_name, arguments, &self.perms, &self.ctx).await
|
||||
}
|
||||
Ok(Ok(false)) => {
|
||||
// Permission denied
|
||||
Err(color_eyre::eyre::eyre!("Permission denied by user"))
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
// Channel closed without response
|
||||
Err(color_eyre::eyre::eyre!("Permission request cancelled"))
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout
|
||||
self.permission_popup = None;
|
||||
self.pending_tool = None;
|
||||
Err(color_eyre::eyre::eyre!("Permission request timed out"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 mut messages = vec![LLMChatMessage::user(user_prompt)];
|
||||
|
||||
let max_iterations = 10;
|
||||
let mut iteration = 0;
|
||||
@@ -286,21 +492,61 @@ impl TuiApp {
|
||||
break;
|
||||
}
|
||||
|
||||
// Call LLM with streaming
|
||||
let mut stream = self.client.chat_stream(&messages, &self.opts, Some(&tools)).await?;
|
||||
// Call LLM with streaming using the LlmProvider trait
|
||||
use llm_core::LlmProvider;
|
||||
let mut stream = self.client
|
||||
.chat_stream(&messages, &self.opts, Some(&tools))
|
||||
.await
|
||||
.map_err(|e| color_eyre::eyre::eyre!("LLM provider error: {}", e))?;
|
||||
|
||||
let mut response_content = String::new();
|
||||
let mut tool_calls = None;
|
||||
let mut accumulated_tool_calls: Vec<llm_core::ToolCall> = Vec::new();
|
||||
|
||||
// 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);
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| color_eyre::eyre::eyre!("Stream error: {}", e))?;
|
||||
|
||||
if let Some(content) = chunk.content {
|
||||
response_content.push_str(&content);
|
||||
// Stream chunks to UI - append to last assistant message
|
||||
self.chat_panel.append_to_assistant(&content);
|
||||
}
|
||||
|
||||
// Accumulate tool calls from deltas
|
||||
if let Some(deltas) = chunk.tool_calls {
|
||||
for delta in deltas {
|
||||
// Ensure the accumulated_tool_calls vec is large enough
|
||||
while accumulated_tool_calls.len() <= delta.index {
|
||||
accumulated_tool_calls.push(llm_core::ToolCall {
|
||||
id: String::new(),
|
||||
call_type: "function".to_string(),
|
||||
function: llm_core::FunctionCall {
|
||||
name: String::new(),
|
||||
arguments: serde_json::Value::Null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let tool_call = &mut accumulated_tool_calls[delta.index];
|
||||
|
||||
if let Some(id) = delta.id {
|
||||
tool_call.id = id;
|
||||
}
|
||||
if let Some(name) = delta.function_name {
|
||||
tool_call.function.name = name;
|
||||
}
|
||||
if let Some(args_delta) = delta.arguments_delta {
|
||||
// Accumulate the arguments string
|
||||
let current_args = if tool_call.function.arguments.is_null() {
|
||||
String::new()
|
||||
} else {
|
||||
tool_call.function.arguments.to_string()
|
||||
};
|
||||
let new_args = current_args + &args_delta;
|
||||
// Try to parse as JSON, but keep as string if incomplete
|
||||
tool_call.function.arguments = serde_json::from_str(&new_args)
|
||||
.unwrap_or_else(|_| serde_json::Value::String(new_args));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,21 +558,29 @@ impl TuiApp {
|
||||
final_response = response_content.clone();
|
||||
}
|
||||
|
||||
// Filter out incomplete tool calls and check if we have valid ones
|
||||
let valid_tool_calls: Vec<_> = accumulated_tool_calls
|
||||
.into_iter()
|
||||
.filter(|tc| !tc.id.is_empty() && !tc.function.name.is_empty())
|
||||
.collect();
|
||||
|
||||
// Check if LLM wants to call tools
|
||||
if let Some(calls) = tool_calls {
|
||||
if !valid_tool_calls.is_empty() {
|
||||
// Add assistant message with tool calls to conversation
|
||||
messages.push(LLMChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
role: llm_core::Role::Assistant,
|
||||
content: if response_content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(response_content.clone())
|
||||
},
|
||||
tool_calls: Some(calls.clone()),
|
||||
tool_calls: Some(valid_tool_calls.clone()),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
|
||||
// Execute each tool call
|
||||
for call in calls {
|
||||
for call in valid_tool_calls {
|
||||
let tool_name = &call.function.name;
|
||||
let arguments = &call.function.arguments;
|
||||
|
||||
@@ -337,7 +591,7 @@ impl TuiApp {
|
||||
});
|
||||
self.stats.record_tool_call();
|
||||
|
||||
match execute_tool(tool_name, arguments, &self.perms).await {
|
||||
match self.execute_tool_with_permission(tool_name, arguments).await {
|
||||
Ok(result) => {
|
||||
// Show success in UI
|
||||
self.chat_panel.add_message(ChatMessage::ToolResult {
|
||||
@@ -346,11 +600,7 @@ impl TuiApp {
|
||||
});
|
||||
|
||||
// Add tool result to conversation
|
||||
messages.push(LLMChatMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(result),
|
||||
tool_calls: None,
|
||||
});
|
||||
messages.push(LLMChatMessage::tool_result(&call.id, result));
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("Error: {}", e);
|
||||
@@ -362,11 +612,7 @@ impl TuiApp {
|
||||
});
|
||||
|
||||
// Add error to conversation
|
||||
messages.push(LLMChatMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(error_msg),
|
||||
tool_calls: None,
|
||||
});
|
||||
messages.push(LLMChatMessage::tool_result(&call.id, error_msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! Borderless chat panel component
|
||||
//!
|
||||
//! Displays chat messages with proper indentation, timestamps,
|
||||
//! and streaming indicators. Uses whitespace instead of borders.
|
||||
|
||||
use crate::theme::Theme;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
||||
widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
||||
Frame,
|
||||
};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// Chat message types
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChatMessage {
|
||||
User(String),
|
||||
@@ -16,176 +23,457 @@ pub enum ChatMessage {
|
||||
System(String),
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// Get a timestamp for when the message was created (for display)
|
||||
pub fn timestamp_display() -> String {
|
||||
let now = SystemTime::now();
|
||||
let secs = now
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let hours = (secs / 3600) % 24;
|
||||
let mins = (secs / 60) % 60;
|
||||
format!("{:02}:{:02}", hours, mins)
|
||||
}
|
||||
}
|
||||
|
||||
/// Message with metadata for display
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DisplayMessage {
|
||||
pub message: ChatMessage,
|
||||
pub timestamp: String,
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
impl DisplayMessage {
|
||||
pub fn new(message: ChatMessage) -> Self {
|
||||
Self {
|
||||
message,
|
||||
timestamp: ChatMessage::timestamp_display(),
|
||||
focused: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Borderless chat panel
|
||||
pub struct ChatPanel {
|
||||
messages: Vec<ChatMessage>,
|
||||
messages: Vec<DisplayMessage>,
|
||||
scroll_offset: usize,
|
||||
auto_scroll: bool,
|
||||
total_lines: usize,
|
||||
focused_index: Option<usize>,
|
||||
is_streaming: bool,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl ChatPanel {
|
||||
/// Create new borderless chat panel
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
auto_scroll: true,
|
||||
total_lines: 0,
|
||||
focused_index: None,
|
||||
is_streaming: false,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a new message
|
||||
pub fn add_message(&mut self, message: ChatMessage) {
|
||||
self.messages.push(message);
|
||||
// Auto-scroll to bottom on new message
|
||||
self.scroll_to_bottom();
|
||||
self.messages.push(DisplayMessage::new(message));
|
||||
self.auto_scroll = true;
|
||||
self.is_streaming = false;
|
||||
}
|
||||
|
||||
/// Append content to the last assistant message, or create a new one if none exists
|
||||
/// Append content to the last assistant message, or create a new one
|
||||
pub fn append_to_assistant(&mut self, content: &str) {
|
||||
if let Some(ChatMessage::Assistant(last_content)) = self.messages.last_mut() {
|
||||
if let Some(DisplayMessage {
|
||||
message: ChatMessage::Assistant(last_content),
|
||||
..
|
||||
}) = self.messages.last_mut()
|
||||
{
|
||||
last_content.push_str(content);
|
||||
} else {
|
||||
self.messages.push(ChatMessage::Assistant(content.to_string()));
|
||||
self.messages.push(DisplayMessage::new(ChatMessage::Assistant(
|
||||
content.to_string(),
|
||||
)));
|
||||
}
|
||||
// Auto-scroll to bottom on update
|
||||
self.scroll_to_bottom();
|
||||
self.auto_scroll = true;
|
||||
self.is_streaming = true;
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
/// Set streaming state
|
||||
pub fn set_streaming(&mut self, streaming: bool) {
|
||||
self.is_streaming = streaming;
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
||||
/// Scroll up
|
||||
pub fn scroll_up(&mut self, amount: usize) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
|
||||
self.auto_scroll = false;
|
||||
}
|
||||
|
||||
/// Scroll down
|
||||
pub fn scroll_down(&mut self, amount: usize) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(amount);
|
||||
let near_bottom_threshold = 5;
|
||||
if self.total_lines > 0 {
|
||||
let max_scroll = self.total_lines.saturating_sub(1);
|
||||
if self.scroll_offset.saturating_add(near_bottom_threshold) >= max_scroll {
|
||||
self.auto_scroll = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll to bottom
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
self.scroll_offset = self.messages.len().saturating_sub(1);
|
||||
self.scroll_offset = self.total_lines.saturating_sub(1);
|
||||
self.auto_scroll = true;
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let mut text_lines = Vec::new();
|
||||
/// Page up
|
||||
pub fn page_up(&mut self, page_size: usize) {
|
||||
self.scroll_up(page_size.saturating_sub(2));
|
||||
}
|
||||
|
||||
for message in &self.messages {
|
||||
match message {
|
||||
/// Page down
|
||||
pub fn page_down(&mut self, page_size: usize) {
|
||||
self.scroll_down(page_size.saturating_sub(2));
|
||||
}
|
||||
|
||||
/// Focus next message
|
||||
pub fn focus_next(&mut self) {
|
||||
if self.messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.focused_index = Some(match self.focused_index {
|
||||
Some(i) if i + 1 < self.messages.len() => i + 1,
|
||||
Some(_) => 0,
|
||||
None => 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Focus previous message
|
||||
pub fn focus_previous(&mut self) {
|
||||
if self.messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.focused_index = Some(match self.focused_index {
|
||||
Some(0) => self.messages.len() - 1,
|
||||
Some(i) => i - 1,
|
||||
None => self.messages.len() - 1,
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear focus
|
||||
pub fn clear_focus(&mut self) {
|
||||
self.focused_index = None;
|
||||
}
|
||||
|
||||
/// Get focused message index
|
||||
pub fn focused_index(&self) -> Option<usize> {
|
||||
self.focused_index
|
||||
}
|
||||
|
||||
/// Get focused message
|
||||
pub fn focused_message(&self) -> Option<&ChatMessage> {
|
||||
self.focused_index
|
||||
.and_then(|i| self.messages.get(i))
|
||||
.map(|m| &m.message)
|
||||
}
|
||||
|
||||
/// Update scroll position before rendering
|
||||
pub fn update_scroll(&mut self, area: Rect) {
|
||||
self.total_lines = self.count_total_lines(area);
|
||||
|
||||
if self.auto_scroll {
|
||||
let visible_height = area.height as usize;
|
||||
let max_scroll = self.total_lines.saturating_sub(visible_height);
|
||||
self.scroll_offset = max_scroll;
|
||||
} else {
|
||||
let visible_height = area.height as usize;
|
||||
let max_scroll = self.total_lines.saturating_sub(visible_height);
|
||||
self.scroll_offset = self.scroll_offset.min(max_scroll);
|
||||
}
|
||||
}
|
||||
|
||||
/// Count total lines for scroll calculation
|
||||
fn count_total_lines(&self, area: Rect) -> usize {
|
||||
let mut line_count = 0;
|
||||
let wrap_width = area.width.saturating_sub(4) as usize;
|
||||
|
||||
for msg in &self.messages {
|
||||
line_count += match &msg.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(""));
|
||||
let wrapped = textwrap::wrap(content, wrap_width);
|
||||
wrapped.len() + 1 // +1 for spacing
|
||||
}
|
||||
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),
|
||||
]));
|
||||
let wrapped = textwrap::wrap(content, wrap_width);
|
||||
wrapped.len() + 1
|
||||
}
|
||||
ChatMessage::ToolCall { .. } => 2,
|
||||
ChatMessage::ToolResult { .. } => 2,
|
||||
ChatMessage::System(_) => 1,
|
||||
};
|
||||
}
|
||||
|
||||
line_count
|
||||
}
|
||||
|
||||
/// Render the borderless chat panel
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let mut text_lines = Vec::new();
|
||||
let wrap_width = area.width.saturating_sub(4) as usize;
|
||||
let symbols = &self.theme.symbols;
|
||||
|
||||
for (idx, display_msg) in self.messages.iter().enumerate() {
|
||||
let is_focused = self.focused_index == Some(idx);
|
||||
let is_last = idx == self.messages.len() - 1;
|
||||
|
||||
match &display_msg.message {
|
||||
ChatMessage::User(content) => {
|
||||
// User message: bright, with prefix
|
||||
let mut role_spans = vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(
|
||||
format!("{} You", symbols.user_prefix),
|
||||
self.theme.user_message,
|
||||
),
|
||||
];
|
||||
|
||||
// Timestamp right-aligned (we'll simplify for now)
|
||||
role_spans.push(Span::styled(
|
||||
format!(" {}", display_msg.timestamp),
|
||||
self.theme.timestamp,
|
||||
));
|
||||
|
||||
text_lines.push(Line::from(role_spans));
|
||||
|
||||
// Message content with 2-space indent
|
||||
let wrapped = textwrap::wrap(content, wrap_width);
|
||||
for line in wrapped {
|
||||
let style = if is_focused {
|
||||
self.theme.user_message.add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
text_lines.push(Line::styled(
|
||||
format!(" {}", line),
|
||||
self.theme.assistant_message,
|
||||
));
|
||||
}
|
||||
self.theme.user_message.remove_modifier(Modifier::BOLD)
|
||||
};
|
||||
text_lines.push(Line::from(Span::styled(
|
||||
format!(" {}", line),
|
||||
style,
|
||||
)));
|
||||
}
|
||||
|
||||
// Focus hints
|
||||
if is_focused {
|
||||
text_lines.push(Line::from(Span::styled(
|
||||
" [y]copy [e]edit [r]retry",
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
}
|
||||
|
||||
text_lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
ChatMessage::Assistant(content) => {
|
||||
// Assistant message: accent color
|
||||
let mut role_spans = vec![Span::styled(" ", Style::default())];
|
||||
|
||||
// Streaming indicator
|
||||
if is_last && self.is_streaming {
|
||||
role_spans.push(Span::styled(
|
||||
format!("{} ", symbols.streaming),
|
||||
Style::default().fg(self.theme.palette.success),
|
||||
));
|
||||
}
|
||||
|
||||
role_spans.push(Span::styled(
|
||||
format!("{} Assistant", symbols.assistant_prefix),
|
||||
self.theme.assistant_message.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
|
||||
role_spans.push(Span::styled(
|
||||
format!(" {}", display_msg.timestamp),
|
||||
self.theme.timestamp,
|
||||
));
|
||||
|
||||
text_lines.push(Line::from(role_spans));
|
||||
|
||||
// Content
|
||||
let wrapped = textwrap::wrap(content, wrap_width);
|
||||
for line in wrapped {
|
||||
let style = if is_focused {
|
||||
self.theme.assistant_message.add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
self.theme.assistant_message
|
||||
};
|
||||
text_lines.push(Line::from(Span::styled(
|
||||
format!(" {}", line),
|
||||
style,
|
||||
)));
|
||||
}
|
||||
|
||||
// Focus hints
|
||||
if is_focused {
|
||||
text_lines.push(Line::from(Span::styled(
|
||||
" [y]copy [r]retry",
|
||||
self.theme.status_dim,
|
||||
)));
|
||||
}
|
||||
|
||||
text_lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
ChatMessage::ToolCall { name, args } => {
|
||||
text_lines.push(Line::from(vec![
|
||||
Span::styled(" ⚡ ", self.theme.tool_call),
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(
|
||||
format!("{} ", name),
|
||||
format!("{} ", symbols.tool_prefix),
|
||||
self.theme.tool_call,
|
||||
),
|
||||
Span::styled(format!("{} ", name), self.theme.tool_call),
|
||||
Span::styled(
|
||||
args,
|
||||
truncate_str(args, 60),
|
||||
self.theme.tool_call.add_modifier(Modifier::DIM),
|
||||
),
|
||||
]));
|
||||
text_lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
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])
|
||||
let icon = if *success {
|
||||
symbols.check
|
||||
} else {
|
||||
output.clone()
|
||||
symbols.cross
|
||||
};
|
||||
|
||||
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(format!(" {} ", icon), style),
|
||||
Span::styled(
|
||||
content,
|
||||
Style::default().fg(self.theme.palette.fg_dim),
|
||||
truncate_str(output, 100),
|
||||
style.add_modifier(Modifier::DIM),
|
||||
),
|
||||
]));
|
||||
text_lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
ChatMessage::System(content) => {
|
||||
text_lines.push(Line::from(vec![
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(
|
||||
format!("{} ", symbols.system_prefix),
|
||||
self.theme.system_message,
|
||||
),
|
||||
Span::styled(content.to_string(), self.theme.system_message),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
let paragraph = Paragraph::new(text).scroll((self.scroll_offset as u16, 0));
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
|
||||
// Render scrollbar if needed
|
||||
if self.messages.len() > area.height as usize {
|
||||
if self.total_lines > 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);
|
||||
.begin_symbol(None)
|
||||
.end_symbol(None)
|
||||
.track_symbol(Some(" "))
|
||||
.thumb_symbol("│")
|
||||
.style(self.theme.status_dim);
|
||||
|
||||
let mut scrollbar_state = ScrollbarState::default()
|
||||
.content_length(self.messages.len())
|
||||
.content_length(self.total_lines)
|
||||
.position(self.scroll_offset);
|
||||
|
||||
frame.render_stateful_widget(
|
||||
scrollbar,
|
||||
area,
|
||||
&mut scrollbar_state,
|
||||
);
|
||||
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn messages(&self) -> &[ChatMessage] {
|
||||
/// Get messages
|
||||
pub fn messages(&self) -> &[DisplayMessage] {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
/// Clear all messages
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.scroll_offset = 0;
|
||||
self.focused_index = None;
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to max length with ellipsis
|
||||
fn truncate_str(s: &str, max_len: usize) -> String {
|
||||
if s.len() <= max_len {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max_len.saturating_sub(3)])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_chat_panel_add_message() {
|
||||
let theme = Theme::default();
|
||||
let mut panel = ChatPanel::new(theme);
|
||||
|
||||
panel.add_message(ChatMessage::User("Hello".to_string()));
|
||||
panel.add_message(ChatMessage::Assistant("Hi there!".to_string()));
|
||||
|
||||
assert_eq!(panel.messages().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append_to_assistant() {
|
||||
let theme = Theme::default();
|
||||
let mut panel = ChatPanel::new(theme);
|
||||
|
||||
panel.append_to_assistant("Hello");
|
||||
panel.append_to_assistant(" world");
|
||||
|
||||
assert_eq!(panel.messages().len(), 1);
|
||||
if let ChatMessage::Assistant(content) = &panel.messages()[0].message {
|
||||
assert_eq!(content, "Hello world");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_focus_navigation() {
|
||||
let theme = Theme::default();
|
||||
let mut panel = ChatPanel::new(theme);
|
||||
|
||||
panel.add_message(ChatMessage::User("1".to_string()));
|
||||
panel.add_message(ChatMessage::User("2".to_string()));
|
||||
panel.add_message(ChatMessage::User("3".to_string()));
|
||||
|
||||
assert_eq!(panel.focused_index(), None);
|
||||
|
||||
panel.focus_next();
|
||||
assert_eq!(panel.focused_index(), Some(0));
|
||||
|
||||
panel.focus_next();
|
||||
assert_eq!(panel.focused_index(), Some(1));
|
||||
|
||||
panel.focus_previous();
|
||||
assert_eq!(panel.focused_index(), Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
use crate::theme::Theme;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
//! Vim-modal input component
|
||||
//!
|
||||
//! Borderless input with vim-like modes (Normal, Insert, Command).
|
||||
//! Uses mode prefix instead of borders for visual indication.
|
||||
|
||||
use crate::theme::{Theme, VimMode};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Padding, Paragraph},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Input event from the input box
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InputEvent {
|
||||
/// User submitted a message
|
||||
Message(String),
|
||||
/// User submitted a command (without / prefix)
|
||||
Command(String),
|
||||
/// Mode changed
|
||||
ModeChange(VimMode),
|
||||
/// Request to cancel current operation
|
||||
Cancel,
|
||||
/// Request to expand input (multiline)
|
||||
Expand,
|
||||
}
|
||||
|
||||
/// Vim-modal input box
|
||||
pub struct InputBox {
|
||||
input: String,
|
||||
cursor_position: usize,
|
||||
history: Vec<String>,
|
||||
history_index: usize,
|
||||
mode: VimMode,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
@@ -23,12 +45,129 @@ impl InputBox {
|
||||
cursor_position: 0,
|
||||
history: Vec::new(),
|
||||
history_index: 0,
|
||||
mode: VimMode::Insert, // Start in insert mode for familiarity
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Option<String> {
|
||||
/// Get current vim mode
|
||||
pub fn mode(&self) -> VimMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Set vim mode
|
||||
pub fn set_mode(&mut self, mode: VimMode) {
|
||||
self.mode = mode;
|
||||
}
|
||||
|
||||
/// Handle key event, returns input event if action is needed
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match self.mode {
|
||||
VimMode::Normal => self.handle_normal_mode(key),
|
||||
VimMode::Insert => self.handle_insert_mode(key),
|
||||
VimMode::Command => self.handle_command_mode(key),
|
||||
VimMode::Visual => self.handle_visual_mode(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keys in normal mode
|
||||
fn handle_normal_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
// Enter insert mode
|
||||
KeyCode::Char('i') => {
|
||||
self.mode = VimMode::Insert;
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.mode = VimMode::Insert;
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
KeyCode::Char('I') => {
|
||||
self.mode = VimMode::Insert;
|
||||
self.cursor_position = 0;
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
KeyCode::Char('A') => {
|
||||
self.mode = VimMode::Insert;
|
||||
self.cursor_position = self.input.len();
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
// Enter command mode
|
||||
KeyCode::Char(':') => {
|
||||
self.mode = VimMode::Command;
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
Some(InputEvent::ModeChange(VimMode::Command))
|
||||
}
|
||||
// Navigation
|
||||
KeyCode::Char('h') | KeyCode::Left => {
|
||||
self.cursor_position = self.cursor_position.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Right => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Char('0') | KeyCode::Home => {
|
||||
self.cursor_position = 0;
|
||||
None
|
||||
}
|
||||
KeyCode::Char('$') | KeyCode::End => {
|
||||
self.cursor_position = self.input.len();
|
||||
None
|
||||
}
|
||||
KeyCode::Char('w') => {
|
||||
// Jump to next word
|
||||
self.cursor_position = self.next_word_position();
|
||||
None
|
||||
}
|
||||
KeyCode::Char('b') => {
|
||||
// Jump to previous word
|
||||
self.cursor_position = self.prev_word_position();
|
||||
None
|
||||
}
|
||||
// Editing
|
||||
KeyCode::Char('x') => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.input.remove(self.cursor_position);
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
// Delete line (dd would require tracking, simplify to clear)
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
None
|
||||
}
|
||||
// History
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
self.history_prev();
|
||||
None
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
self.history_next();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keys in insert mode
|
||||
fn handle_insert_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = VimMode::Normal;
|
||||
// Move cursor back when exiting insert mode (vim behavior)
|
||||
if self.cursor_position > 0 {
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let message = self.input.clone();
|
||||
if !message.trim().is_empty() {
|
||||
@@ -36,109 +175,333 @@ impl InputBox {
|
||||
self.history_index = self.history.len();
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
return Some(message);
|
||||
return Some(InputEvent::Message(message));
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(InputEvent::Expand)
|
||||
}
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(InputEvent::Cancel)
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input.insert(self.cursor_position, c);
|
||||
self.cursor_position += 1;
|
||||
None
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.cursor_position > 0 {
|
||||
self.input.remove(self.cursor_position - 1);
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.input.remove(self.cursor_position);
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.cursor_position = self.cursor_position.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.cursor_position = 0;
|
||||
None
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.cursor_position = self.input.len();
|
||||
None
|
||||
}
|
||||
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();
|
||||
}
|
||||
self.history_prev();
|
||||
None
|
||||
}
|
||||
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;
|
||||
}
|
||||
self.history_next();
|
||||
None
|
||||
}
|
||||
_ => {}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Handle keys in command mode
|
||||
fn handle_command_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = VimMode::Normal;
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let command = self.input.clone();
|
||||
self.mode = VimMode::Normal;
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
if !command.trim().is_empty() {
|
||||
return Some(InputEvent::Command(command));
|
||||
}
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input.insert(self.cursor_position, c);
|
||||
self.cursor_position += 1;
|
||||
None
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.cursor_position > 0 {
|
||||
self.input.remove(self.cursor_position - 1);
|
||||
self.cursor_position -= 1;
|
||||
} else {
|
||||
// Empty command, exit to normal mode
|
||||
self.mode = VimMode::Normal;
|
||||
return Some(InputEvent::ModeChange(VimMode::Normal));
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.cursor_position = self.cursor_position.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keys in visual mode (simplified)
|
||||
fn handle_visual_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = VimMode::Normal;
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// History navigation - previous
|
||||
fn history_prev(&mut self) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// History navigation - next
|
||||
fn history_next(&mut self) {
|
||||
if self.history_index < self.history.len().saturating_sub(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// Find next word position
|
||||
fn next_word_position(&self) -> usize {
|
||||
let bytes = self.input.as_bytes();
|
||||
let mut pos = self.cursor_position;
|
||||
|
||||
// Skip current word
|
||||
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
// Skip whitespace
|
||||
while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find previous word position
|
||||
fn prev_word_position(&self) -> usize {
|
||||
let bytes = self.input.as_bytes();
|
||||
let mut pos = self.cursor_position.saturating_sub(1);
|
||||
|
||||
// Skip whitespace
|
||||
while pos > 0 && bytes[pos].is_ascii_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
// Skip to start of word
|
||||
while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// Render the borderless input (single line)
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let is_empty = self.input.is_empty();
|
||||
let symbols = &self.theme.symbols;
|
||||
|
||||
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[..], "")
|
||||
// Mode-specific prefix
|
||||
let prefix = match self.mode {
|
||||
VimMode::Normal => Span::styled(
|
||||
format!("{} ", symbols.mode_normal),
|
||||
self.theme.status_dim,
|
||||
),
|
||||
VimMode::Insert => Span::styled(
|
||||
format!("{} ", symbols.user_prefix),
|
||||
self.theme.input_prefix,
|
||||
),
|
||||
VimMode::Command => Span::styled(
|
||||
": ",
|
||||
self.theme.input_prefix,
|
||||
),
|
||||
VimMode::Visual => Span::styled(
|
||||
format!("{} ", symbols.mode_visual),
|
||||
self.theme.status_accent,
|
||||
),
|
||||
};
|
||||
|
||||
let line = if is_empty {
|
||||
// Cursor position handling
|
||||
let (text_before, cursor_char, text_after) = if self.cursor_position < self.input.len() {
|
||||
let before = &self.input[..self.cursor_position];
|
||||
let cursor = &self.input[self.cursor_position..self.cursor_position + 1];
|
||||
let after = &self.input[self.cursor_position + 1..];
|
||||
(before, cursor, after)
|
||||
} else {
|
||||
(&self.input[..], " ", "")
|
||||
};
|
||||
|
||||
let line = if is_empty && self.mode == VimMode::Insert {
|
||||
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)),
|
||||
Span::raw(" "),
|
||||
prefix,
|
||||
Span::styled("▊", self.theme.input_prefix),
|
||||
Span::styled(" Type message...", self.theme.input_placeholder),
|
||||
])
|
||||
} else if is_empty && self.mode == VimMode::Command {
|
||||
Line::from(vec![
|
||||
Span::raw(" "),
|
||||
prefix,
|
||||
Span::styled("▊", self.theme.input_prefix),
|
||||
])
|
||||
} else {
|
||||
// Build cursor span with appropriate styling
|
||||
let cursor_style = if self.mode == VimMode::Normal {
|
||||
Style::default()
|
||||
.bg(self.theme.palette.fg)
|
||||
.fg(self.theme.palette.bg)
|
||||
} else {
|
||||
self.theme.input_prefix
|
||||
};
|
||||
|
||||
let cursor_span = if self.mode == VimMode::Normal && !is_empty {
|
||||
Span::styled(cursor_char.to_string(), cursor_style)
|
||||
} else {
|
||||
Span::styled("▊", self.theme.input_prefix)
|
||||
};
|
||||
|
||||
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),
|
||||
Span::raw(" "),
|
||||
prefix,
|
||||
Span::styled(text_before.to_string(), self.theme.input_text),
|
||||
cursor_span,
|
||||
Span::styled(text_after.to_string(), self.theme.input_text),
|
||||
])
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(line).block(block);
|
||||
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
/// Clear input
|
||||
pub fn clear(&mut self) {
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
|
||||
/// Get current input text
|
||||
pub fn text(&self) -> &str {
|
||||
&self.input
|
||||
}
|
||||
|
||||
/// Set input text
|
||||
pub fn set_text(&mut self, text: String) {
|
||||
self.input = text;
|
||||
self.cursor_position = self.input.len();
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mode_transitions() {
|
||||
let theme = Theme::default();
|
||||
let mut input = InputBox::new(theme);
|
||||
|
||||
// Start in insert mode
|
||||
assert_eq!(input.mode(), VimMode::Insert);
|
||||
|
||||
// Escape to normal mode
|
||||
let event = input.handle_key(KeyEvent::from(KeyCode::Esc));
|
||||
assert!(matches!(event, Some(InputEvent::ModeChange(VimMode::Normal))));
|
||||
assert_eq!(input.mode(), VimMode::Normal);
|
||||
|
||||
// 'i' to insert mode
|
||||
let event = input.handle_key(KeyEvent::from(KeyCode::Char('i')));
|
||||
assert!(matches!(event, Some(InputEvent::ModeChange(VimMode::Insert))));
|
||||
assert_eq!(input.mode(), VimMode::Insert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_text() {
|
||||
let theme = Theme::default();
|
||||
let mut input = InputBox::new(theme);
|
||||
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('h')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('i')));
|
||||
|
||||
assert_eq!(input.text(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_mode() {
|
||||
let theme = Theme::default();
|
||||
let mut input = InputBox::new(theme);
|
||||
|
||||
// Escape to normal, then : to command
|
||||
input.handle_key(KeyEvent::from(KeyCode::Esc));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char(':')));
|
||||
|
||||
assert_eq!(input.mode(), VimMode::Command);
|
||||
|
||||
// Type command
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('q')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('u')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('i')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('t')));
|
||||
|
||||
assert_eq!(input.text(), "quit");
|
||||
|
||||
// Submit command
|
||||
let event = input.handle_key(KeyEvent::from(KeyCode::Enter));
|
||||
assert!(matches!(event, Some(InputEvent::Command(cmd)) if cmd == "quit"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! TUI components for the borderless multi-provider design
|
||||
|
||||
mod chat_panel;
|
||||
mod input_box;
|
||||
mod permission_popup;
|
||||
mod provider_tabs;
|
||||
mod status_bar;
|
||||
|
||||
pub use chat_panel::{ChatMessage, ChatPanel};
|
||||
pub use input_box::InputBox;
|
||||
pub use chat_panel::{ChatMessage, ChatPanel, DisplayMessage};
|
||||
pub use input_box::{InputBox, InputEvent};
|
||||
pub use permission_popup::{PermissionOption, PermissionPopup};
|
||||
pub use status_bar::StatusBar;
|
||||
pub use provider_tabs::ProviderTabs;
|
||||
pub use status_bar::{AppState, StatusBar};
|
||||
|
||||
@@ -136,7 +136,7 @@ impl PermissionPopup {
|
||||
// Separator
|
||||
let separator = Line::styled(
|
||||
"─".repeat(sections[2].width as usize),
|
||||
Style::default().fg(self.theme.palette.border),
|
||||
Style::default().fg(self.theme.palette.divider_fg),
|
||||
);
|
||||
frame.render_widget(Paragraph::new(separator), sections[2]);
|
||||
|
||||
|
||||
189
crates/app/ui/src/components/provider_tabs.rs
Normal file
189
crates/app/ui/src/components/provider_tabs.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
//! Provider tabs component for multi-LLM support
|
||||
//!
|
||||
//! Displays horizontal tabs for switching between providers (Claude, Ollama, OpenAI)
|
||||
//! with icons and keybind hints.
|
||||
|
||||
use crate::theme::{Provider, Theme};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Provider tab state and rendering
|
||||
pub struct ProviderTabs {
|
||||
active: Provider,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl ProviderTabs {
|
||||
/// Create new provider tabs with default provider
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
active: Provider::Ollama, // Default to Ollama (local)
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with specific active provider
|
||||
pub fn with_provider(provider: Provider, theme: Theme) -> Self {
|
||||
Self {
|
||||
active: provider,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently active provider
|
||||
pub fn active(&self) -> Provider {
|
||||
self.active
|
||||
}
|
||||
|
||||
/// Set the active provider
|
||||
pub fn set_active(&mut self, provider: Provider) {
|
||||
self.active = provider;
|
||||
}
|
||||
|
||||
/// Cycle to the next provider
|
||||
pub fn next(&mut self) {
|
||||
self.active = match self.active {
|
||||
Provider::Claude => Provider::Ollama,
|
||||
Provider::Ollama => Provider::OpenAI,
|
||||
Provider::OpenAI => Provider::Claude,
|
||||
};
|
||||
}
|
||||
|
||||
/// Cycle to the previous provider
|
||||
pub fn previous(&mut self) {
|
||||
self.active = match self.active {
|
||||
Provider::Claude => Provider::OpenAI,
|
||||
Provider::Ollama => Provider::Claude,
|
||||
Provider::OpenAI => Provider::Ollama,
|
||||
};
|
||||
}
|
||||
|
||||
/// Select provider by number (1, 2, 3)
|
||||
pub fn select_by_number(&mut self, num: u8) {
|
||||
self.active = match num {
|
||||
1 => Provider::Claude,
|
||||
2 => Provider::Ollama,
|
||||
3 => Provider::OpenAI,
|
||||
_ => self.active,
|
||||
};
|
||||
}
|
||||
|
||||
/// Update the theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
/// Render the provider tabs (borderless)
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
// Add spacing at start
|
||||
spans.push(Span::raw(" "));
|
||||
|
||||
for (i, provider) in Provider::all().iter().enumerate() {
|
||||
let is_active = *provider == self.active;
|
||||
let icon = self.theme.provider_icon(*provider);
|
||||
let name = provider.name();
|
||||
let number = (i + 1).to_string();
|
||||
|
||||
// Keybind hint
|
||||
spans.push(Span::styled(
|
||||
format!("[{}] ", number),
|
||||
self.theme.status_dim,
|
||||
));
|
||||
|
||||
// Icon and name
|
||||
let style = if is_active {
|
||||
Style::default()
|
||||
.fg(self.theme.provider_color(*provider))
|
||||
.add_modifier(ratatui::style::Modifier::BOLD)
|
||||
} else {
|
||||
self.theme.tab_inactive
|
||||
};
|
||||
|
||||
spans.push(Span::styled(format!("{} ", icon), style));
|
||||
spans.push(Span::styled(name.to_string(), style));
|
||||
|
||||
// Separator between tabs (not after last)
|
||||
if i < Provider::all().len() - 1 {
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", self.theme.symbols.vertical_separator),
|
||||
self.theme.status_dim,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Tab cycling hint on the right
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled("[Tab] cycle", self.theme.status_dim));
|
||||
|
||||
let line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
/// Render a compact version (just active provider)
|
||||
pub fn render_compact(&self, frame: &mut Frame, area: Rect) {
|
||||
let icon = self.theme.provider_icon(self.active);
|
||||
let name = self.active.name();
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{} {}", icon, name),
|
||||
Style::default()
|
||||
.fg(self.theme.provider_color(self.active))
|
||||
.add_modifier(ratatui::style::Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_provider_cycling() {
|
||||
let theme = Theme::default();
|
||||
let mut tabs = ProviderTabs::new(theme);
|
||||
|
||||
assert_eq!(tabs.active(), Provider::Ollama);
|
||||
|
||||
tabs.next();
|
||||
assert_eq!(tabs.active(), Provider::OpenAI);
|
||||
|
||||
tabs.next();
|
||||
assert_eq!(tabs.active(), Provider::Claude);
|
||||
|
||||
tabs.next();
|
||||
assert_eq!(tabs.active(), Provider::Ollama);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_by_number() {
|
||||
let theme = Theme::default();
|
||||
let mut tabs = ProviderTabs::new(theme);
|
||||
|
||||
tabs.select_by_number(1);
|
||||
assert_eq!(tabs.active(), Provider::Claude);
|
||||
|
||||
tabs.select_by_number(2);
|
||||
assert_eq!(tabs.active(), Provider::Ollama);
|
||||
|
||||
tabs.select_by_number(3);
|
||||
assert_eq!(tabs.active(), Provider::OpenAI);
|
||||
|
||||
// Invalid number should not change
|
||||
tabs.select_by_number(4);
|
||||
assert_eq!(tabs.active(), Provider::OpenAI);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
use crate::theme::Theme;
|
||||
//! Multi-provider status bar component
|
||||
//!
|
||||
//! Borderless status bar showing provider, model, mode, stats, and state.
|
||||
//! Format: model │ Mode │ N msgs │ N │ ~Nk │ $0.00 │ ● status
|
||||
|
||||
use crate::theme::{Provider, Theme, VimMode};
|
||||
use agent_core::SessionStats;
|
||||
use permissions::Mode;
|
||||
use ratatui::{
|
||||
@@ -8,102 +13,221 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Application state for status display
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AppState {
|
||||
Idle,
|
||||
Streaming,
|
||||
WaitingPermission,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
AppState::Idle => "○",
|
||||
AppState::Streaming => "●",
|
||||
AppState::WaitingPermission => "◐",
|
||||
AppState::Error => "✗",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
AppState::Idle => "idle",
|
||||
AppState::Streaming => "streaming",
|
||||
AppState::WaitingPermission => "waiting",
|
||||
AppState::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StatusBar {
|
||||
provider: Provider,
|
||||
model: String,
|
||||
mode: Mode,
|
||||
vim_mode: VimMode,
|
||||
stats: SessionStats,
|
||||
last_tool: Option<String>,
|
||||
state: AppState,
|
||||
estimated_cost: f64,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl StatusBar {
|
||||
pub fn new(model: String, mode: Mode, theme: Theme) -> Self {
|
||||
Self {
|
||||
provider: Provider::Ollama, // Default provider
|
||||
model,
|
||||
mode,
|
||||
vim_mode: VimMode::Insert,
|
||||
stats: SessionStats::new(),
|
||||
last_tool: None,
|
||||
state: AppState::Idle,
|
||||
estimated_cost: 0.0,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the active provider
|
||||
pub fn set_provider(&mut self, provider: Provider) {
|
||||
self.provider = provider;
|
||||
}
|
||||
|
||||
/// Set the current model
|
||||
pub fn set_model(&mut self, model: String) {
|
||||
self.model = model;
|
||||
}
|
||||
|
||||
/// Update session stats
|
||||
pub fn update_stats(&mut self, stats: SessionStats) {
|
||||
self.stats = stats;
|
||||
}
|
||||
|
||||
/// Set the last used tool
|
||||
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);
|
||||
/// Set application state
|
||||
pub fn set_state(&mut self, state: AppState) {
|
||||
self.state = state;
|
||||
}
|
||||
|
||||
let (mode_str, mode_icon) = match self.mode {
|
||||
Mode::Plan => ("Plan", "🔍"),
|
||||
Mode::AcceptEdits => ("AcceptEdits", "✏️"),
|
||||
Mode::Code => ("Code", "⚡"),
|
||||
/// Set vim mode for display
|
||||
pub fn set_vim_mode(&mut self, mode: VimMode) {
|
||||
self.vim_mode = mode;
|
||||
}
|
||||
|
||||
/// Add to estimated cost
|
||||
pub fn add_cost(&mut self, cost: f64) {
|
||||
self.estimated_cost += cost;
|
||||
}
|
||||
|
||||
/// Reset cost
|
||||
pub fn reset_cost(&mut self) {
|
||||
self.estimated_cost = 0.0;
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
/// Render the status bar
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let symbols = &self.theme.symbols;
|
||||
let sep = symbols.vertical_separator;
|
||||
|
||||
// Provider icon and model
|
||||
let provider_icon = self.theme.provider_icon(self.provider);
|
||||
let provider_style = ratatui::style::Style::default()
|
||||
.fg(self.theme.provider_color(self.provider));
|
||||
|
||||
// Permission mode
|
||||
let mode_str = match self.mode {
|
||||
Mode::Plan => "Plan",
|
||||
Mode::AcceptEdits => "Edit",
|
||||
Mode::Code => "Code",
|
||||
};
|
||||
|
||||
let last_tool_str = self
|
||||
.last_tool
|
||||
.as_ref()
|
||||
.map(|t| format!("● {}", t))
|
||||
.unwrap_or_else(|| "○ idle".to_string());
|
||||
// Format token count
|
||||
let tokens_str = if self.stats.estimated_tokens >= 1000 {
|
||||
format!("~{}k", self.stats.estimated_tokens / 1000)
|
||||
} else {
|
||||
format!("~{}", self.stats.estimated_tokens)
|
||||
};
|
||||
|
||||
// Build status line with colorful sections
|
||||
let separator_style = self.theme.status_bar;
|
||||
// Cost display (only for paid providers)
|
||||
let cost_str = if self.provider != Provider::Ollama && self.estimated_cost > 0.0 {
|
||||
format!("${:.2}", self.estimated_cost)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// State indicator
|
||||
let state_style = match self.state {
|
||||
AppState::Idle => self.theme.status_dim,
|
||||
AppState::Streaming => ratatui::style::Style::default()
|
||||
.fg(self.theme.palette.success),
|
||||
AppState::WaitingPermission => ratatui::style::Style::default()
|
||||
.fg(self.theme.palette.warning),
|
||||
AppState::Error => ratatui::style::Style::default()
|
||||
.fg(self.theme.palette.error),
|
||||
};
|
||||
|
||||
// Build status line
|
||||
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.theme.status_bar),
|
||||
// Provider icon and model
|
||||
Span::styled(format!("{} ", provider_icon), provider_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),
|
||||
Span::styled(format!(" {} ", sep), self.theme.status_dim),
|
||||
// Permission mode
|
||||
Span::styled(mode_str, self.theme.status_bar),
|
||||
Span::styled(format!(" {} ", sep), self.theme.status_dim),
|
||||
// Message count
|
||||
Span::styled(format!("{} msgs", self.stats.total_messages), self.theme.status_bar),
|
||||
Span::styled(format!(" {} ", sep), self.theme.status_dim),
|
||||
// Tool count
|
||||
Span::styled(format!("{} {}", symbols.tool_prefix, self.stats.total_tool_calls), self.theme.status_bar),
|
||||
Span::styled(format!(" {} ", sep), self.theme.status_dim),
|
||||
// Token count
|
||||
Span::styled(tokens_str, self.theme.status_bar),
|
||||
];
|
||||
|
||||
// Add help text on the right
|
||||
let help_text = " ? /help ";
|
||||
// Add cost if applicable
|
||||
if !cost_str.is_empty() {
|
||||
spans.push(Span::styled(format!(" {} ", sep), self.theme.status_dim));
|
||||
spans.push(Span::styled(cost_str, self.theme.status_accent));
|
||||
}
|
||||
|
||||
// Calculate current length
|
||||
let current_len: usize = spans.iter()
|
||||
// State indicator
|
||||
spans.push(Span::styled(format!(" {} ", sep), self.theme.status_dim));
|
||||
spans.push(Span::styled(
|
||||
format!("{} {}", self.state.icon(), self.state.label()),
|
||||
state_style,
|
||||
));
|
||||
|
||||
// Calculate current width
|
||||
let current_width: 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);
|
||||
// Add help hint on the right
|
||||
let vim_indicator = self.vim_mode.indicator(&self.theme.symbols);
|
||||
let help_hint = format!("{} ?", vim_indicator);
|
||||
let help_width = unicode_width::UnicodeWidthStr::width(help_hint.as_str()) + 2;
|
||||
|
||||
spans.push(Span::styled(" ".repeat(padding as usize), separator_style));
|
||||
spans.push(Span::styled(help_text, self.theme.status_bar));
|
||||
// Padding
|
||||
let available = area.width as usize;
|
||||
let padding = available.saturating_sub(current_width + help_width);
|
||||
spans.push(Span::raw(" ".repeat(padding)));
|
||||
spans.push(Span::styled(help_hint, self.theme.status_dim));
|
||||
spans.push(Span::raw(" "));
|
||||
|
||||
let line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(line);
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_status_bar_creation() {
|
||||
let theme = Theme::default();
|
||||
let status_bar = StatusBar::new("gpt-4".to_string(), Mode::Plan, theme);
|
||||
assert_eq!(status_bar.model, "gpt-4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_state_display() {
|
||||
assert_eq!(AppState::Idle.label(), "idle");
|
||||
assert_eq!(AppState::Streaming.label(), "streaming");
|
||||
assert_eq!(AppState::Error.icon(), "✗");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,10 @@ pub enum AppEvent {
|
||||
StatusUpdate(agent_core::SessionStats),
|
||||
/// Terminal was resized
|
||||
Resize { width: u16, height: u16 },
|
||||
/// Mouse scroll up
|
||||
ScrollUp,
|
||||
/// Mouse scroll down
|
||||
ScrollDown,
|
||||
/// Application should quit
|
||||
Quit,
|
||||
}
|
||||
|
||||
532
crates/app/ui/src/formatting.rs
Normal file
532
crates/app/ui/src/formatting.rs
Normal file
@@ -0,0 +1,532 @@
|
||||
//! Output formatting with markdown parsing and syntax highlighting
|
||||
//!
|
||||
//! This module provides rich text rendering for the TUI, converting markdown
|
||||
//! content into styled ratatui spans with proper syntax highlighting for code blocks.
|
||||
|
||||
use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use syntect::easy::HighlightLines;
|
||||
use syntect::highlighting::{Theme, ThemeSet};
|
||||
use syntect::parsing::SyntaxSet;
|
||||
use syntect::util::LinesWithEndings;
|
||||
|
||||
/// Highlighter for syntax highlighting code blocks
|
||||
pub struct SyntaxHighlighter {
|
||||
syntax_set: SyntaxSet,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl SyntaxHighlighter {
|
||||
/// Create a new syntax highlighter with default theme
|
||||
pub fn new() -> Self {
|
||||
let syntax_set = SyntaxSet::load_defaults_newlines();
|
||||
let theme_set = ThemeSet::load_defaults();
|
||||
// Use a dark theme that works well in terminals
|
||||
let theme = theme_set.themes["base16-ocean.dark"].clone();
|
||||
|
||||
Self { syntax_set, theme }
|
||||
}
|
||||
|
||||
/// Create highlighter with a specific theme name
|
||||
pub fn with_theme(theme_name: &str) -> Self {
|
||||
let syntax_set = SyntaxSet::load_defaults_newlines();
|
||||
let theme_set = ThemeSet::load_defaults();
|
||||
let theme = theme_set
|
||||
.themes
|
||||
.get(theme_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| theme_set.themes["base16-ocean.dark"].clone());
|
||||
|
||||
Self { syntax_set, theme }
|
||||
}
|
||||
|
||||
/// Get available theme names
|
||||
pub fn available_themes() -> Vec<&'static str> {
|
||||
vec![
|
||||
"base16-ocean.dark",
|
||||
"base16-eighties.dark",
|
||||
"base16-mocha.dark",
|
||||
"base16-ocean.light",
|
||||
"InspiredGitHub",
|
||||
"Solarized (dark)",
|
||||
"Solarized (light)",
|
||||
]
|
||||
}
|
||||
|
||||
/// Highlight a code block and return styled lines
|
||||
pub fn highlight_code(&self, code: &str, language: &str) -> Vec<Line<'static>> {
|
||||
// Find syntax for the language
|
||||
let syntax = self
|
||||
.syntax_set
|
||||
.find_syntax_by_token(language)
|
||||
.or_else(|| self.syntax_set.find_syntax_by_extension(language))
|
||||
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
|
||||
|
||||
let mut highlighter = HighlightLines::new(syntax, &self.theme);
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for line in LinesWithEndings::from(code) {
|
||||
let Ok(ranges) = highlighter.highlight_line(line, &self.syntax_set) else {
|
||||
// Fallback to plain text if highlighting fails
|
||||
lines.push(Line::from(Span::raw(line.trim_end().to_string())));
|
||||
continue;
|
||||
};
|
||||
|
||||
let spans: Vec<Span<'static>> = ranges
|
||||
.into_iter()
|
||||
.map(|(style, text)| {
|
||||
let fg = syntect_to_ratatui_color(style.foreground);
|
||||
let ratatui_style = Style::default().fg(fg);
|
||||
Span::styled(text.trim_end_matches('\n').to_string(), ratatui_style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SyntaxHighlighter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert syntect color to ratatui color
|
||||
fn syntect_to_ratatui_color(color: syntect::highlighting::Color) -> Color {
|
||||
Color::Rgb(color.r, color.g, color.b)
|
||||
}
|
||||
|
||||
/// Parsed markdown content ready for rendering
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FormattedContent {
|
||||
pub lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
impl FormattedContent {
|
||||
/// Create empty formatted content
|
||||
pub fn empty() -> Self {
|
||||
Self { lines: Vec::new() }
|
||||
}
|
||||
|
||||
/// Get the number of lines
|
||||
pub fn len(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
/// Check if content is empty
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lines.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Markdown parser that converts markdown to styled ratatui lines
|
||||
pub struct MarkdownRenderer {
|
||||
highlighter: SyntaxHighlighter,
|
||||
}
|
||||
|
||||
impl MarkdownRenderer {
|
||||
/// Create a new markdown renderer
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
highlighter: SyntaxHighlighter::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create renderer with custom highlighter
|
||||
pub fn with_highlighter(highlighter: SyntaxHighlighter) -> Self {
|
||||
Self { highlighter }
|
||||
}
|
||||
|
||||
/// Render markdown text to formatted content
|
||||
pub fn render(&self, markdown: &str) -> FormattedContent {
|
||||
let parser = Parser::new(markdown);
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut current_line_spans: Vec<Span<'static>> = Vec::new();
|
||||
|
||||
// State tracking
|
||||
let mut in_code_block = false;
|
||||
let mut code_block_lang = String::new();
|
||||
let mut code_block_content = String::new();
|
||||
let mut current_style = Style::default();
|
||||
let mut list_depth: usize = 0;
|
||||
let mut ordered_list_index: Option<u64> = None;
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(tag) => match tag {
|
||||
Tag::Heading { level, .. } => {
|
||||
// Flush current line
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
// Style for headings
|
||||
current_style = match level {
|
||||
pulldown_cmark::HeadingLevel::H1 => Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
pulldown_cmark::HeadingLevel::H2 => Style::default()
|
||||
.fg(Color::Blue)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
pulldown_cmark::HeadingLevel::H3 => Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
_ => Style::default().add_modifier(Modifier::BOLD),
|
||||
};
|
||||
// Add heading prefix
|
||||
let prefix = "#".repeat(level as usize);
|
||||
current_line_spans.push(Span::styled(
|
||||
format!("{} ", prefix),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
}
|
||||
Tag::Paragraph => {
|
||||
// Start a new paragraph
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
}
|
||||
Tag::CodeBlock(kind) => {
|
||||
in_code_block = true;
|
||||
code_block_content.clear();
|
||||
code_block_lang = match kind {
|
||||
CodeBlockKind::Fenced(lang) => lang.to_string(),
|
||||
CodeBlockKind::Indented => String::new(),
|
||||
};
|
||||
// Flush current line and add code block header
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
// Add code fence line
|
||||
let fence_line = if code_block_lang.is_empty() {
|
||||
"```".to_string()
|
||||
} else {
|
||||
format!("```{}", code_block_lang)
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
fence_line,
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
Tag::List(start) => {
|
||||
list_depth += 1;
|
||||
ordered_list_index = start;
|
||||
}
|
||||
Tag::Item => {
|
||||
// Flush current line
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
// Add list marker
|
||||
let indent = " ".repeat(list_depth.saturating_sub(1));
|
||||
let marker = if let Some(idx) = ordered_list_index {
|
||||
ordered_list_index = Some(idx + 1);
|
||||
format!("{}{}. ", indent, idx)
|
||||
} else {
|
||||
format!("{}- ", indent)
|
||||
};
|
||||
current_line_spans.push(Span::styled(
|
||||
marker,
|
||||
Style::default().fg(Color::Yellow),
|
||||
));
|
||||
}
|
||||
Tag::Emphasis => {
|
||||
current_style = current_style.add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
Tag::Strong => {
|
||||
current_style = current_style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
Tag::Strikethrough => {
|
||||
current_style = current_style.add_modifier(Modifier::CROSSED_OUT);
|
||||
}
|
||||
Tag::Link { dest_url, .. } => {
|
||||
current_style = Style::default()
|
||||
.fg(Color::Blue)
|
||||
.add_modifier(Modifier::UNDERLINED);
|
||||
// Store URL for later
|
||||
current_line_spans.push(Span::styled(
|
||||
"[",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
// URL will be shown after link text
|
||||
code_block_content = dest_url.to_string();
|
||||
}
|
||||
Tag::BlockQuote(_) => {
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
current_line_spans.push(Span::styled(
|
||||
"│ ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
current_style = Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::End(tag_end) => match tag_end {
|
||||
TagEnd::Heading(_) => {
|
||||
current_style = Style::default();
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
TagEnd::Paragraph => {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
lines.push(Line::from("")); // Empty line after paragraph
|
||||
}
|
||||
TagEnd::CodeBlock => {
|
||||
in_code_block = false;
|
||||
// Highlight and add code content
|
||||
let highlighted =
|
||||
self.highlighter.highlight_code(&code_block_content, &code_block_lang);
|
||||
lines.extend(highlighted);
|
||||
// Add closing fence
|
||||
lines.push(Line::from(Span::styled(
|
||||
"```",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
code_block_content.clear();
|
||||
code_block_lang.clear();
|
||||
}
|
||||
TagEnd::List(_) => {
|
||||
list_depth = list_depth.saturating_sub(1);
|
||||
if list_depth == 0 {
|
||||
ordered_list_index = None;
|
||||
}
|
||||
}
|
||||
TagEnd::Item => {
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
}
|
||||
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
|
||||
current_style = Style::default();
|
||||
}
|
||||
TagEnd::Link => {
|
||||
current_line_spans.push(Span::styled(
|
||||
"]",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
current_line_spans.push(Span::styled(
|
||||
format!("({})", code_block_content),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
code_block_content.clear();
|
||||
current_style = Style::default();
|
||||
}
|
||||
TagEnd::BlockQuote => {
|
||||
current_style = Style::default();
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::Text(text) => {
|
||||
if in_code_block {
|
||||
code_block_content.push_str(&text);
|
||||
} else {
|
||||
current_line_spans.push(Span::styled(text.to_string(), current_style));
|
||||
}
|
||||
}
|
||||
Event::Code(code) => {
|
||||
// Inline code
|
||||
current_line_spans.push(Span::styled(
|
||||
format!("`{}`", code),
|
||||
Style::default().fg(Color::Magenta),
|
||||
));
|
||||
}
|
||||
Event::SoftBreak => {
|
||||
current_line_spans.push(Span::raw(" "));
|
||||
}
|
||||
Event::HardBreak => {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
Event::Rule => {
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(std::mem::take(&mut current_line_spans)));
|
||||
}
|
||||
lines.push(Line::from(Span::styled(
|
||||
"─".repeat(40),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining content
|
||||
if !current_line_spans.is_empty() {
|
||||
lines.push(Line::from(current_line_spans));
|
||||
}
|
||||
|
||||
FormattedContent { lines }
|
||||
}
|
||||
|
||||
/// Render plain text (no markdown parsing)
|
||||
pub fn render_plain(&self, text: &str) -> FormattedContent {
|
||||
let lines = text
|
||||
.lines()
|
||||
.map(|line| Line::from(Span::raw(line.to_string())))
|
||||
.collect();
|
||||
FormattedContent { lines }
|
||||
}
|
||||
|
||||
/// Render a diff with +/- highlighting
|
||||
pub fn render_diff(&self, diff: &str) -> FormattedContent {
|
||||
let lines = diff
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let style = if line.starts_with('+') && !line.starts_with("+++") {
|
||||
Style::default().fg(Color::Green)
|
||||
} else if line.starts_with('-') && !line.starts_with("---") {
|
||||
Style::default().fg(Color::Red)
|
||||
} else if line.starts_with("@@") {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else if line.starts_with("diff ") || line.starts_with("index ") {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
Line::from(Span::styled(line.to_string(), style))
|
||||
})
|
||||
.collect();
|
||||
FormattedContent { lines }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MarkdownRenderer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a file path with syntax highlighting based on extension
|
||||
pub fn format_file_path(path: &str) -> Span<'static> {
|
||||
let color = if path.ends_with(".rs") {
|
||||
Color::Rgb(222, 165, 132) // Rust orange
|
||||
} else if path.ends_with(".toml") {
|
||||
Color::Rgb(156, 220, 254) // Light blue
|
||||
} else if path.ends_with(".md") {
|
||||
Color::Rgb(86, 156, 214) // Blue
|
||||
} else if path.ends_with(".json") {
|
||||
Color::Rgb(206, 145, 120) // Brown
|
||||
} else if path.ends_with(".ts") || path.ends_with(".tsx") {
|
||||
Color::Rgb(49, 120, 198) // TypeScript blue
|
||||
} else if path.ends_with(".js") || path.ends_with(".jsx") {
|
||||
Color::Rgb(241, 224, 90) // JavaScript yellow
|
||||
} else if path.ends_with(".py") {
|
||||
Color::Rgb(55, 118, 171) // Python blue
|
||||
} else if path.ends_with(".go") {
|
||||
Color::Rgb(0, 173, 216) // Go cyan
|
||||
} else if path.ends_with(".sh") || path.ends_with(".bash") {
|
||||
Color::Rgb(137, 224, 81) // Shell green
|
||||
} else {
|
||||
Color::White
|
||||
};
|
||||
|
||||
Span::styled(path.to_string(), Style::default().fg(color))
|
||||
}
|
||||
|
||||
/// Format a tool name with appropriate styling
|
||||
pub fn format_tool_name(name: &str) -> Span<'static> {
|
||||
let style = Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
Span::styled(name.to_string(), style)
|
||||
}
|
||||
|
||||
/// Format an error message
|
||||
pub fn format_error(message: &str) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled("Error: ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(message.to_string(), Style::default().fg(Color::Red)),
|
||||
])
|
||||
}
|
||||
|
||||
/// Format a success message
|
||||
pub fn format_success(message: &str) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled("✓ ", Style::default().fg(Color::Green)),
|
||||
Span::styled(message.to_string(), Style::default().fg(Color::Green)),
|
||||
])
|
||||
}
|
||||
|
||||
/// Format a warning message
|
||||
pub fn format_warning(message: &str) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled("⚠ ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(message.to_string(), Style::default().fg(Color::Yellow)),
|
||||
])
|
||||
}
|
||||
|
||||
/// Format an info message
|
||||
pub fn format_info(message: &str) -> Line<'static> {
|
||||
Line::from(vec![
|
||||
Span::styled("ℹ ", Style::default().fg(Color::Blue)),
|
||||
Span::styled(message.to_string(), Style::default().fg(Color::Blue)),
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_syntax_highlighter_creation() {
|
||||
let highlighter = SyntaxHighlighter::new();
|
||||
let lines = highlighter.highlight_code("fn main() {}", "rust");
|
||||
assert!(!lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_render_heading() {
|
||||
let renderer = MarkdownRenderer::new();
|
||||
let content = renderer.render("# Hello World");
|
||||
assert!(!content.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_render_code_block() {
|
||||
let renderer = MarkdownRenderer::new();
|
||||
let content = renderer.render("```rust\nfn main() {}\n```");
|
||||
assert!(content.len() >= 3); // Opening fence, code, closing fence
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_render_list() {
|
||||
let renderer = MarkdownRenderer::new();
|
||||
let content = renderer.render("- Item 1\n- Item 2\n- Item 3");
|
||||
assert!(content.len() >= 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_rendering() {
|
||||
let renderer = MarkdownRenderer::new();
|
||||
let diff = "+added line\n-removed line\n unchanged";
|
||||
let content = renderer.render_diff(diff);
|
||||
assert_eq!(content.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_file_path() {
|
||||
let span = format_file_path("src/main.rs");
|
||||
assert!(span.content.contains("main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_messages() {
|
||||
let error = format_error("Something went wrong");
|
||||
assert!(!error.spans.is_empty());
|
||||
|
||||
let success = format_success("Operation completed");
|
||||
assert!(!success.spans.is_empty());
|
||||
|
||||
let warning = format_warning("Be careful");
|
||||
assert!(!warning.spans.is_empty());
|
||||
|
||||
let info = format_info("FYI");
|
||||
assert!(!info.spans.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,112 @@
|
||||
//! Layout calculation for the borderless TUI
|
||||
//!
|
||||
//! Uses vertical layout with whitespace for visual hierarchy instead of borders:
|
||||
//! - Header row (app name, mode, model, help)
|
||||
//! - Provider tabs
|
||||
//! - Horizontal divider
|
||||
//! - Chat area (scrollable)
|
||||
//! - Horizontal divider
|
||||
//! - Input area
|
||||
//! - Status bar
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
|
||||
/// Calculate layout areas for the TUI
|
||||
/// Calculated layout areas for the borderless TUI
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AppLayout {
|
||||
/// Header row: app name, mode indicator, model, help hint
|
||||
pub header_area: Rect,
|
||||
/// Provider tabs row
|
||||
pub tabs_area: Rect,
|
||||
/// Top divider (horizontal rule)
|
||||
pub top_divider: Rect,
|
||||
/// Main chat/message area
|
||||
pub chat_area: Rect,
|
||||
/// Bottom divider (horizontal rule)
|
||||
pub bottom_divider: Rect,
|
||||
/// Input area for user text
|
||||
pub input_area: Rect,
|
||||
/// Status bar at the bottom
|
||||
pub status_area: Rect,
|
||||
}
|
||||
|
||||
impl AppLayout {
|
||||
/// Calculate layout from terminal size
|
||||
/// Calculate layout for the given 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)
|
||||
Constraint::Length(1), // Header
|
||||
Constraint::Length(1), // Provider tabs
|
||||
Constraint::Length(1), // Top divider
|
||||
Constraint::Min(5), // Chat area (flexible)
|
||||
Constraint::Length(1), // Bottom divider
|
||||
Constraint::Length(1), // Input
|
||||
Constraint::Length(1), // Status bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Self {
|
||||
chat_area: chunks[0],
|
||||
input_area: chunks[1],
|
||||
status_area: chunks[2],
|
||||
header_area: chunks[0],
|
||||
tabs_area: chunks[1],
|
||||
top_divider: chunks[2],
|
||||
chat_area: chunks[3],
|
||||
bottom_divider: chunks[4],
|
||||
input_area: chunks[5],
|
||||
status_area: chunks[6],
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate layout with expanded input (multiline)
|
||||
pub fn calculate_expanded_input(area: Rect, input_lines: u16) -> Self {
|
||||
let input_height = input_lines.min(10).max(1); // Cap at 10 lines
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // Header
|
||||
Constraint::Length(1), // Provider tabs
|
||||
Constraint::Length(1), // Top divider
|
||||
Constraint::Min(5), // Chat area (flexible)
|
||||
Constraint::Length(1), // Bottom divider
|
||||
Constraint::Length(input_height), // Expanded input
|
||||
Constraint::Length(1), // Status bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Self {
|
||||
header_area: chunks[0],
|
||||
tabs_area: chunks[1],
|
||||
top_divider: chunks[2],
|
||||
chat_area: chunks[3],
|
||||
bottom_divider: chunks[4],
|
||||
input_area: chunks[5],
|
||||
status_area: chunks[6],
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate layout without tabs (compact mode)
|
||||
pub fn calculate_compact(area: Rect) -> Self {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(1), // Header (includes compact provider indicator)
|
||||
Constraint::Length(1), // Top divider
|
||||
Constraint::Min(5), // Chat area (flexible)
|
||||
Constraint::Length(1), // Bottom divider
|
||||
Constraint::Length(1), // Input
|
||||
Constraint::Length(1), // Status bar
|
||||
])
|
||||
.split(area);
|
||||
|
||||
Self {
|
||||
header_area: chunks[0],
|
||||
tabs_area: Rect::default(), // No tabs area in compact mode
|
||||
top_divider: chunks[1],
|
||||
chat_area: chunks[2],
|
||||
bottom_divider: chunks[3],
|
||||
input_area: chunks[4],
|
||||
status_area: chunks[5],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +131,53 @@ impl AppLayout {
|
||||
.split(popup_layout[1])[1]
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout mode based on terminal width
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LayoutMode {
|
||||
/// Full layout with provider tabs (>= 80 cols)
|
||||
Full,
|
||||
/// Compact layout without tabs (< 80 cols)
|
||||
Compact,
|
||||
}
|
||||
|
||||
impl LayoutMode {
|
||||
/// Determine layout mode based on terminal width
|
||||
pub fn for_width(width: u16) -> Self {
|
||||
if width >= 80 {
|
||||
LayoutMode::Full
|
||||
} else {
|
||||
LayoutMode::Compact
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_layout_calculation() {
|
||||
let area = Rect::new(0, 0, 120, 40);
|
||||
let layout = AppLayout::calculate(area);
|
||||
|
||||
// Header should be at top
|
||||
assert_eq!(layout.header_area.y, 0);
|
||||
assert_eq!(layout.header_area.height, 1);
|
||||
|
||||
// Status should be at bottom
|
||||
assert_eq!(layout.status_area.y, 39);
|
||||
assert_eq!(layout.status_area.height, 1);
|
||||
|
||||
// Chat area should have most of the space
|
||||
assert!(layout.chat_area.height > 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_layout_mode() {
|
||||
assert_eq!(LayoutMode::for_width(80), LayoutMode::Full);
|
||||
assert_eq!(LayoutMode::for_width(120), LayoutMode::Full);
|
||||
assert_eq!(LayoutMode::for_width(79), LayoutMode::Compact);
|
||||
assert_eq!(LayoutMode::for_width(60), LayoutMode::Compact);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
pub mod app;
|
||||
pub mod components;
|
||||
pub mod events;
|
||||
pub mod formatting;
|
||||
pub mod layout;
|
||||
pub mod theme;
|
||||
|
||||
pub use app::TuiApp;
|
||||
pub use events::AppEvent;
|
||||
pub use formatting::{
|
||||
FormattedContent, MarkdownRenderer, SyntaxHighlighter,
|
||||
format_file_path, format_tool_name, format_error, format_success, format_warning, format_info,
|
||||
};
|
||||
|
||||
use color_eyre::eyre::Result;
|
||||
|
||||
/// Run the TUI application
|
||||
pub async fn run(
|
||||
client: llm_ollama::OllamaClient,
|
||||
opts: llm_ollama::OllamaOptions,
|
||||
opts: llm_core::ChatOptions,
|
||||
perms: permissions::PermissionManager,
|
||||
settings: config_agent::Settings,
|
||||
) -> Result<()> {
|
||||
|
||||
@@ -1,5 +1,145 @@
|
||||
//! Theme system for the borderless TUI design
|
||||
//!
|
||||
//! Provides color palettes, semantic styling, and terminal capability detection
|
||||
//! for graceful degradation across different terminal emulators.
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
/// Terminal capability detection for graceful degradation
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TerminalCapability {
|
||||
/// Full Unicode support with true color
|
||||
Full,
|
||||
/// Basic Unicode with 256 colors
|
||||
Unicode256,
|
||||
/// ASCII only with 16 colors
|
||||
Basic,
|
||||
}
|
||||
|
||||
impl TerminalCapability {
|
||||
/// Detect terminal capabilities from environment
|
||||
pub fn detect() -> Self {
|
||||
// Check for true color support
|
||||
let colorterm = std::env::var("COLORTERM").unwrap_or_default();
|
||||
let term = std::env::var("TERM").unwrap_or_default();
|
||||
|
||||
if colorterm == "truecolor" || colorterm == "24bit" {
|
||||
return Self::Full;
|
||||
}
|
||||
|
||||
if term.contains("256color") || term.contains("kitty") || term.contains("alacritty") {
|
||||
return Self::Unicode256;
|
||||
}
|
||||
|
||||
// Check if we're in a linux VT or basic terminal
|
||||
if term == "linux" || term == "vt100" || term == "dumb" {
|
||||
return Self::Basic;
|
||||
}
|
||||
|
||||
// Default to unicode with 256 colors
|
||||
Self::Unicode256
|
||||
}
|
||||
|
||||
/// Check if Unicode box drawing is supported
|
||||
pub fn supports_unicode(&self) -> bool {
|
||||
matches!(self, Self::Full | Self::Unicode256)
|
||||
}
|
||||
|
||||
/// Check if true color (RGB) is supported
|
||||
pub fn supports_truecolor(&self) -> bool {
|
||||
matches!(self, Self::Full)
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbols with fallbacks for different terminal capabilities
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Symbols {
|
||||
pub horizontal_rule: &'static str,
|
||||
pub vertical_separator: &'static str,
|
||||
pub bullet: &'static str,
|
||||
pub arrow: &'static str,
|
||||
pub check: &'static str,
|
||||
pub cross: &'static str,
|
||||
pub warning: &'static str,
|
||||
pub info: &'static str,
|
||||
pub streaming: &'static str,
|
||||
pub user_prefix: &'static str,
|
||||
pub assistant_prefix: &'static str,
|
||||
pub tool_prefix: &'static str,
|
||||
pub system_prefix: &'static str,
|
||||
// Provider icons
|
||||
pub claude_icon: &'static str,
|
||||
pub ollama_icon: &'static str,
|
||||
pub openai_icon: &'static str,
|
||||
// Vim mode indicators
|
||||
pub mode_normal: &'static str,
|
||||
pub mode_insert: &'static str,
|
||||
pub mode_visual: &'static str,
|
||||
pub mode_command: &'static str,
|
||||
}
|
||||
|
||||
impl Symbols {
|
||||
/// Unicode symbols for capable terminals
|
||||
pub fn unicode() -> Self {
|
||||
Self {
|
||||
horizontal_rule: "─",
|
||||
vertical_separator: "│",
|
||||
bullet: "•",
|
||||
arrow: "→",
|
||||
check: "✓",
|
||||
cross: "✗",
|
||||
warning: "⚠",
|
||||
info: "ℹ",
|
||||
streaming: "●",
|
||||
user_prefix: "❯",
|
||||
assistant_prefix: "◆",
|
||||
tool_prefix: "⚡",
|
||||
system_prefix: "○",
|
||||
claude_icon: "",
|
||||
ollama_icon: "",
|
||||
openai_icon: "",
|
||||
mode_normal: "[N]",
|
||||
mode_insert: "[I]",
|
||||
mode_visual: "[V]",
|
||||
mode_command: "[:]",
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII fallback symbols
|
||||
pub fn ascii() -> Self {
|
||||
Self {
|
||||
horizontal_rule: "-",
|
||||
vertical_separator: "|",
|
||||
bullet: "*",
|
||||
arrow: "->",
|
||||
check: "+",
|
||||
cross: "x",
|
||||
warning: "!",
|
||||
info: "i",
|
||||
streaming: "*",
|
||||
user_prefix: ">",
|
||||
assistant_prefix: "-",
|
||||
tool_prefix: "#",
|
||||
system_prefix: "-",
|
||||
claude_icon: "C",
|
||||
ollama_icon: "O",
|
||||
openai_icon: "G",
|
||||
mode_normal: "[N]",
|
||||
mode_insert: "[I]",
|
||||
mode_visual: "[V]",
|
||||
mode_command: "[:]",
|
||||
}
|
||||
}
|
||||
|
||||
/// Select symbols based on terminal capability
|
||||
pub fn for_capability(cap: TerminalCapability) -> Self {
|
||||
match cap {
|
||||
TerminalCapability::Full | TerminalCapability::Unicode256 => Self::unicode(),
|
||||
TerminalCapability::Basic => Self::ascii(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Modern color palette inspired by contemporary design systems
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColorPalette {
|
||||
@@ -13,8 +153,18 @@ pub struct ColorPalette {
|
||||
pub bg: Color,
|
||||
pub fg: Color,
|
||||
pub fg_dim: Color,
|
||||
pub border: Color,
|
||||
pub fg_muted: Color,
|
||||
pub highlight: Color,
|
||||
// Provider-specific colors
|
||||
pub claude: Color,
|
||||
pub ollama: Color,
|
||||
pub openai: Color,
|
||||
// Semantic colors for borderless design
|
||||
pub user_fg: Color,
|
||||
pub assistant_fg: Color,
|
||||
pub tool_fg: Color,
|
||||
pub timestamp_fg: Color,
|
||||
pub divider_fg: Color,
|
||||
}
|
||||
|
||||
impl ColorPalette {
|
||||
@@ -31,8 +181,18 @@ impl ColorPalette {
|
||||
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
|
||||
fg_muted: Color::Rgb(65, 72, 104), // Very dim
|
||||
highlight: Color::Rgb(56, 62, 90), // Selection bg
|
||||
// Provider colors
|
||||
claude: Color::Rgb(217, 119, 87), // Claude orange
|
||||
ollama: Color::Rgb(122, 162, 247), // Blue
|
||||
openai: Color::Rgb(16, 163, 127), // OpenAI green
|
||||
// Semantic
|
||||
user_fg: Color::Rgb(255, 255, 255), // Bright white for user
|
||||
assistant_fg: Color::Rgb(125, 207, 255), // Cyan for AI
|
||||
tool_fg: Color::Rgb(224, 175, 104), // Yellow for tools
|
||||
timestamp_fg: Color::Rgb(65, 72, 104), // Very dim
|
||||
divider_fg: Color::Rgb(56, 62, 90), // Subtle divider
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,8 +209,16 @@ impl ColorPalette {
|
||||
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
|
||||
fg_muted: Color::Rgb(68, 71, 90), // Very dim
|
||||
highlight: Color::Rgb(68, 71, 90), // Selection
|
||||
claude: Color::Rgb(255, 121, 198),
|
||||
ollama: Color::Rgb(139, 233, 253),
|
||||
openai: Color::Rgb(80, 250, 123),
|
||||
user_fg: Color::Rgb(248, 248, 242),
|
||||
assistant_fg: Color::Rgb(139, 233, 253),
|
||||
tool_fg: Color::Rgb(241, 250, 140),
|
||||
timestamp_fg: Color::Rgb(68, 71, 90),
|
||||
divider_fg: Color::Rgb(68, 71, 90),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +235,16 @@ impl ColorPalette {
|
||||
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
|
||||
fg_muted: Color::Rgb(69, 71, 90), // Surface
|
||||
highlight: Color::Rgb(49, 50, 68), // Surface
|
||||
claude: Color::Rgb(245, 194, 231),
|
||||
ollama: Color::Rgb(137, 180, 250),
|
||||
openai: Color::Rgb(166, 227, 161),
|
||||
user_fg: Color::Rgb(205, 214, 244),
|
||||
assistant_fg: Color::Rgb(148, 226, 213),
|
||||
tool_fg: Color::Rgb(249, 226, 175),
|
||||
timestamp_fg: Color::Rgb(69, 71, 90),
|
||||
divider_fg: Color::Rgb(69, 71, 90),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +261,16 @@ impl ColorPalette {
|
||||
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
|
||||
fg_muted: Color::Rgb(59, 66, 82),
|
||||
highlight: Color::Rgb(59, 66, 82), // Selection
|
||||
claude: Color::Rgb(180, 142, 173),
|
||||
ollama: Color::Rgb(136, 192, 208),
|
||||
openai: Color::Rgb(163, 190, 140),
|
||||
user_fg: Color::Rgb(236, 239, 244),
|
||||
assistant_fg: Color::Rgb(136, 192, 208),
|
||||
tool_fg: Color::Rgb(235, 203, 139),
|
||||
timestamp_fg: Color::Rgb(59, 66, 82),
|
||||
divider_fg: Color::Rgb(59, 66, 82),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,8 +287,16 @@ impl ColorPalette {
|
||||
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
|
||||
fg_muted: Color::Rgb(72, 12, 168),
|
||||
highlight: Color::Rgb(72, 12, 168), // Deep purple
|
||||
claude: Color::Rgb(255, 128, 0),
|
||||
ollama: Color::Rgb(0, 229, 255),
|
||||
openai: Color::Rgb(0, 255, 157),
|
||||
user_fg: Color::Rgb(242, 233, 255),
|
||||
assistant_fg: Color::Rgb(0, 229, 255),
|
||||
tool_fg: Color::Rgb(255, 215, 0),
|
||||
timestamp_fg: Color::Rgb(72, 12, 168),
|
||||
divider_fg: Color::Rgb(72, 12, 168),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,8 +313,16 @@ impl ColorPalette {
|
||||
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
|
||||
fg_muted: Color::Rgb(42, 39, 63),
|
||||
highlight: Color::Rgb(42, 39, 63), // Highlight
|
||||
claude: Color::Rgb(234, 154, 151),
|
||||
ollama: Color::Rgb(156, 207, 216),
|
||||
openai: Color::Rgb(49, 116, 143),
|
||||
user_fg: Color::Rgb(224, 222, 244),
|
||||
assistant_fg: Color::Rgb(156, 207, 216),
|
||||
tool_fg: Color::Rgb(246, 193, 119),
|
||||
timestamp_fg: Color::Rgb(42, 39, 63),
|
||||
divider_fg: Color::Rgb(42, 39, 63),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,43 +339,121 @@ impl ColorPalette {
|
||||
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
|
||||
fg_muted: Color::Rgb(13, 43, 69),
|
||||
highlight: Color::Rgb(13, 43, 69), // Deep blue
|
||||
claude: Color::Rgb(199, 146, 234),
|
||||
ollama: Color::Rgb(102, 217, 239),
|
||||
openai: Color::Rgb(163, 190, 140),
|
||||
user_fg: Color::Rgb(201, 211, 235),
|
||||
assistant_fg: Color::Rgb(102, 217, 239),
|
||||
tool_fg: Color::Rgb(229, 200, 144),
|
||||
timestamp_fg: Color::Rgb(13, 43, 69),
|
||||
divider_fg: Color::Rgb(13, 43, 69),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme configuration for the TUI
|
||||
/// LLM Provider enum
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Provider {
|
||||
Claude,
|
||||
Ollama,
|
||||
OpenAI,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Provider::Claude => "Claude",
|
||||
Provider::Ollama => "Ollama",
|
||||
Provider::OpenAI => "OpenAI",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all() -> &'static [Provider] {
|
||||
&[Provider::Claude, Provider::Ollama, Provider::OpenAI]
|
||||
}
|
||||
}
|
||||
|
||||
/// Vim-like editing mode
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum VimMode {
|
||||
#[default]
|
||||
Normal,
|
||||
Insert,
|
||||
Visual,
|
||||
Command,
|
||||
}
|
||||
|
||||
impl VimMode {
|
||||
pub fn indicator(&self, symbols: &Symbols) -> &'static str {
|
||||
match self {
|
||||
VimMode::Normal => symbols.mode_normal,
|
||||
VimMode::Insert => symbols.mode_insert,
|
||||
VimMode::Visual => symbols.mode_visual,
|
||||
VimMode::Command => symbols.mode_command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme configuration for the borderless TUI
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Theme {
|
||||
pub palette: ColorPalette,
|
||||
pub symbols: Symbols,
|
||||
pub capability: TerminalCapability,
|
||||
// Message styles
|
||||
pub user_message: Style,
|
||||
pub assistant_message: Style,
|
||||
pub tool_call: Style,
|
||||
pub tool_result_success: Style,
|
||||
pub tool_result_error: Style,
|
||||
pub system_message: Style,
|
||||
pub timestamp: Style,
|
||||
// UI element styles
|
||||
pub divider: Style,
|
||||
pub header: Style,
|
||||
pub header_accent: Style,
|
||||
pub tab_active: Style,
|
||||
pub tab_inactive: Style,
|
||||
pub input_prefix: Style,
|
||||
pub input_text: Style,
|
||||
pub input_placeholder: Style,
|
||||
pub status_bar: Style,
|
||||
pub status_bar_highlight: Style,
|
||||
pub input_box: Style,
|
||||
pub input_box_active: Style,
|
||||
pub status_accent: Style,
|
||||
pub status_dim: Style,
|
||||
// Popup styles (for permission dialogs)
|
||||
pub popup_border: Style,
|
||||
pub popup_bg: Style,
|
||||
pub popup_title: Style,
|
||||
pub selected: Style,
|
||||
// Legacy compatibility
|
||||
pub border: Style,
|
||||
pub border_active: Style,
|
||||
pub status_bar_highlight: Style,
|
||||
pub input_box: Style,
|
||||
pub input_box_active: Style,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// Create theme from color palette
|
||||
/// Create theme from color palette with automatic capability detection
|
||||
pub fn from_palette(palette: ColorPalette) -> Self {
|
||||
let capability = TerminalCapability::detect();
|
||||
Self::from_palette_with_capability(palette, capability)
|
||||
}
|
||||
|
||||
/// Create theme with specific terminal capability
|
||||
pub fn from_palette_with_capability(palette: ColorPalette, capability: TerminalCapability) -> Self {
|
||||
let symbols = Symbols::for_capability(capability);
|
||||
|
||||
Self {
|
||||
// Message styles
|
||||
user_message: Style::default()
|
||||
.fg(palette.primary)
|
||||
.fg(palette.user_fg)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
assistant_message: Style::default().fg(palette.fg),
|
||||
assistant_message: Style::default().fg(palette.assistant_fg),
|
||||
tool_call: Style::default()
|
||||
.fg(palette.warning)
|
||||
.fg(palette.tool_fg)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
tool_result_success: Style::default()
|
||||
.fg(palette.success)
|
||||
@@ -183,18 +461,29 @@ impl Theme {
|
||||
tool_result_error: Style::default()
|
||||
.fg(palette.error)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
status_bar: Style::default()
|
||||
.fg(palette.bg)
|
||||
.bg(palette.primary)
|
||||
system_message: Style::default().fg(palette.fg_dim),
|
||||
timestamp: Style::default().fg(palette.timestamp_fg),
|
||||
// UI elements
|
||||
divider: Style::default().fg(palette.divider_fg),
|
||||
header: Style::default()
|
||||
.fg(palette.fg)
|
||||
.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()
|
||||
header_accent: Style::default()
|
||||
.fg(palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
tab_active: Style::default()
|
||||
.fg(palette.primary)
|
||||
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
|
||||
tab_inactive: Style::default().fg(palette.fg_dim),
|
||||
input_prefix: Style::default()
|
||||
.fg(palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
input_text: Style::default().fg(palette.fg),
|
||||
input_placeholder: Style::default().fg(palette.fg_muted),
|
||||
status_bar: Style::default().fg(palette.fg_dim),
|
||||
status_accent: Style::default().fg(palette.accent),
|
||||
status_dim: Style::default().fg(palette.fg_muted),
|
||||
// Popup styles
|
||||
popup_border: Style::default()
|
||||
.fg(palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -206,14 +495,48 @@ impl Theme {
|
||||
.fg(palette.bg)
|
||||
.bg(palette.accent)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
border: Style::default().fg(palette.border),
|
||||
// Legacy compatibility
|
||||
border: Style::default().fg(palette.fg_dim),
|
||||
border_active: Style::default()
|
||||
.fg(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),
|
||||
symbols,
|
||||
capability,
|
||||
palette,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get provider-specific color
|
||||
pub fn provider_color(&self, provider: Provider) -> Color {
|
||||
match provider {
|
||||
Provider::Claude => self.palette.claude,
|
||||
Provider::Ollama => self.palette.ollama,
|
||||
Provider::OpenAI => self.palette.openai,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get provider icon
|
||||
pub fn provider_icon(&self, provider: Provider) -> &str {
|
||||
match provider {
|
||||
Provider::Claude => self.symbols.claude_icon,
|
||||
Provider::Ollama => self.symbols.ollama_icon,
|
||||
Provider::OpenAI => self.symbols.openai_icon,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a horizontal rule string of given width
|
||||
pub fn horizontal_rule(&self, width: usize) -> String {
|
||||
self.symbols.horizontal_rule.repeat(width)
|
||||
}
|
||||
|
||||
/// Tokyo Night theme (default) - modern and vibrant
|
||||
pub fn tokyo_night() -> Self {
|
||||
Self::from_palette(ColorPalette::tokyo_night())
|
||||
@@ -255,3 +578,48 @@ impl Default for Theme {
|
||||
Self::tokyo_night()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_terminal_capability_detection() {
|
||||
let cap = TerminalCapability::detect();
|
||||
// Should return some valid capability
|
||||
assert!(matches!(
|
||||
cap,
|
||||
TerminalCapability::Full | TerminalCapability::Unicode256 | TerminalCapability::Basic
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_symbols_for_capability() {
|
||||
let unicode = Symbols::for_capability(TerminalCapability::Full);
|
||||
assert_eq!(unicode.horizontal_rule, "─");
|
||||
|
||||
let ascii = Symbols::for_capability(TerminalCapability::Basic);
|
||||
assert_eq!(ascii.horizontal_rule, "-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_theme_from_palette() {
|
||||
let theme = Theme::tokyo_night();
|
||||
assert!(theme.capability.supports_unicode() || !theme.capability.supports_unicode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_provider_colors() {
|
||||
let theme = Theme::tokyo_night();
|
||||
let claude_color = theme.provider_color(Provider::Claude);
|
||||
let ollama_color = theme.provider_color(Provider::Ollama);
|
||||
assert_ne!(claude_color, ollama_color);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vim_mode_indicator() {
|
||||
let symbols = Symbols::unicode();
|
||||
assert_eq!(VimMode::Normal.indicator(&symbols), "[N]");
|
||||
assert_eq!(VimMode::Insert.indicator(&symbols), "[I]");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user