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:
2025-12-02 17:24:14 +01:00
parent 09c8c9d83e
commit 10c8e2baae
67 changed files with 11444 additions and 626 deletions

View File

@@ -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"

View File

@@ -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(),

View File

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

View File

@@ -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" }

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]);

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

View File

@@ -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(), "");
}
}

View File

@@ -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,
}

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

View File

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

View File

@@ -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<()> {

View File

@@ -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]");
}
}