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

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