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>
480 lines
15 KiB
Rust
480 lines
15 KiB
Rust
//! 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::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
|
Frame,
|
|
};
|
|
use std::time::SystemTime;
|
|
|
|
/// Chat message types
|
|
#[derive(Debug, Clone)]
|
|
pub enum ChatMessage {
|
|
User(String),
|
|
Assistant(String),
|
|
ToolCall { name: String, args: String },
|
|
ToolResult { success: bool, output: String },
|
|
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<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(DisplayMessage::new(message));
|
|
self.auto_scroll = true;
|
|
self.is_streaming = false;
|
|
}
|
|
|
|
/// Append content to the last assistant message, or create a new one
|
|
pub fn append_to_assistant(&mut self, content: &str) {
|
|
if let Some(DisplayMessage {
|
|
message: ChatMessage::Assistant(last_content),
|
|
..
|
|
}) = self.messages.last_mut()
|
|
{
|
|
last_content.push_str(content);
|
|
} else {
|
|
self.messages.push(DisplayMessage::new(ChatMessage::Assistant(
|
|
content.to_string(),
|
|
)));
|
|
}
|
|
self.auto_scroll = true;
|
|
self.is_streaming = true;
|
|
}
|
|
|
|
/// Set streaming state
|
|
pub fn set_streaming(&mut self, streaming: bool) {
|
|
self.is_streaming = streaming;
|
|
}
|
|
|
|
/// 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.total_lines.saturating_sub(1);
|
|
self.auto_scroll = true;
|
|
}
|
|
|
|
/// Page up
|
|
pub fn page_up(&mut self, page_size: usize) {
|
|
self.scroll_up(page_size.saturating_sub(2));
|
|
}
|
|
|
|
/// 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) => {
|
|
let wrapped = textwrap::wrap(content, wrap_width);
|
|
wrapped.len() + 1 // +1 for spacing
|
|
}
|
|
ChatMessage::Assistant(content) => {
|
|
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 {
|
|
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(" ", Style::default()),
|
|
Span::styled(
|
|
format!("{} ", symbols.tool_prefix),
|
|
self.theme.tool_call,
|
|
),
|
|
Span::styled(format!("{} ", name), self.theme.tool_call),
|
|
Span::styled(
|
|
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 {
|
|
symbols.check
|
|
} else {
|
|
symbols.cross
|
|
};
|
|
|
|
text_lines.push(Line::from(vec![
|
|
Span::styled(format!(" {} ", icon), style),
|
|
Span::styled(
|
|
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 paragraph = Paragraph::new(text).scroll((self.scroll_offset as u16, 0));
|
|
|
|
frame.render_widget(paragraph, area);
|
|
|
|
// Render scrollbar if needed
|
|
if self.total_lines > area.height as usize {
|
|
let scrollbar = Scrollbar::default()
|
|
.orientation(ScrollbarOrientation::VerticalRight)
|
|
.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.total_lines)
|
|
.position(self.scroll_offset);
|
|
|
|
frame.render_stateful_widget(scrollbar, area, &mut scrollbar_state);
|
|
}
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
}
|