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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user