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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
use crate::theme::Theme;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
//! Vim-modal input component
|
||||
//!
|
||||
//! Borderless input with vim-like modes (Normal, Insert, Command).
|
||||
//! Uses mode prefix instead of borders for visual indication.
|
||||
|
||||
use crate::theme::{Theme, VimMode};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Padding, Paragraph},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Input event from the input box
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum InputEvent {
|
||||
/// User submitted a message
|
||||
Message(String),
|
||||
/// User submitted a command (without / prefix)
|
||||
Command(String),
|
||||
/// Mode changed
|
||||
ModeChange(VimMode),
|
||||
/// Request to cancel current operation
|
||||
Cancel,
|
||||
/// Request to expand input (multiline)
|
||||
Expand,
|
||||
}
|
||||
|
||||
/// Vim-modal input box
|
||||
pub struct InputBox {
|
||||
input: String,
|
||||
cursor_position: usize,
|
||||
history: Vec<String>,
|
||||
history_index: usize,
|
||||
mode: VimMode,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
@@ -23,12 +45,129 @@ impl InputBox {
|
||||
cursor_position: 0,
|
||||
history: Vec::new(),
|
||||
history_index: 0,
|
||||
mode: VimMode::Insert, // Start in insert mode for familiarity
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Option<String> {
|
||||
/// Get current vim mode
|
||||
pub fn mode(&self) -> VimMode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Set vim mode
|
||||
pub fn set_mode(&mut self, mode: VimMode) {
|
||||
self.mode = mode;
|
||||
}
|
||||
|
||||
/// Handle key event, returns input event if action is needed
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match self.mode {
|
||||
VimMode::Normal => self.handle_normal_mode(key),
|
||||
VimMode::Insert => self.handle_insert_mode(key),
|
||||
VimMode::Command => self.handle_command_mode(key),
|
||||
VimMode::Visual => self.handle_visual_mode(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keys in normal mode
|
||||
fn handle_normal_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
// Enter insert mode
|
||||
KeyCode::Char('i') => {
|
||||
self.mode = VimMode::Insert;
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
KeyCode::Char('a') => {
|
||||
self.mode = VimMode::Insert;
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
KeyCode::Char('I') => {
|
||||
self.mode = VimMode::Insert;
|
||||
self.cursor_position = 0;
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
KeyCode::Char('A') => {
|
||||
self.mode = VimMode::Insert;
|
||||
self.cursor_position = self.input.len();
|
||||
Some(InputEvent::ModeChange(VimMode::Insert))
|
||||
}
|
||||
// Enter command mode
|
||||
KeyCode::Char(':') => {
|
||||
self.mode = VimMode::Command;
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
Some(InputEvent::ModeChange(VimMode::Command))
|
||||
}
|
||||
// Navigation
|
||||
KeyCode::Char('h') | KeyCode::Left => {
|
||||
self.cursor_position = self.cursor_position.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
KeyCode::Char('l') | KeyCode::Right => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Char('0') | KeyCode::Home => {
|
||||
self.cursor_position = 0;
|
||||
None
|
||||
}
|
||||
KeyCode::Char('$') | KeyCode::End => {
|
||||
self.cursor_position = self.input.len();
|
||||
None
|
||||
}
|
||||
KeyCode::Char('w') => {
|
||||
// Jump to next word
|
||||
self.cursor_position = self.next_word_position();
|
||||
None
|
||||
}
|
||||
KeyCode::Char('b') => {
|
||||
// Jump to previous word
|
||||
self.cursor_position = self.prev_word_position();
|
||||
None
|
||||
}
|
||||
// Editing
|
||||
KeyCode::Char('x') => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.input.remove(self.cursor_position);
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Char('d') => {
|
||||
// Delete line (dd would require tracking, simplify to clear)
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
None
|
||||
}
|
||||
// History
|
||||
KeyCode::Char('k') | KeyCode::Up => {
|
||||
self.history_prev();
|
||||
None
|
||||
}
|
||||
KeyCode::Char('j') | KeyCode::Down => {
|
||||
self.history_next();
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keys in insert mode
|
||||
fn handle_insert_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = VimMode::Normal;
|
||||
// Move cursor back when exiting insert mode (vim behavior)
|
||||
if self.cursor_position > 0 {
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let message = self.input.clone();
|
||||
if !message.trim().is_empty() {
|
||||
@@ -36,109 +175,333 @@ impl InputBox {
|
||||
self.history_index = self.history.len();
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
return Some(message);
|
||||
return Some(InputEvent::Message(message));
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(InputEvent::Expand)
|
||||
}
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(InputEvent::Cancel)
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input.insert(self.cursor_position, c);
|
||||
self.cursor_position += 1;
|
||||
None
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.cursor_position > 0 {
|
||||
self.input.remove(self.cursor_position - 1);
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.input.remove(self.cursor_position);
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.cursor_position = self.cursor_position.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.cursor_position = 0;
|
||||
None
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.cursor_position = self.input.len();
|
||||
None
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if !self.history.is_empty() && self.history_index > 0 {
|
||||
self.history_index -= 1;
|
||||
self.input = self.history[self.history_index].clone();
|
||||
self.cursor_position = self.input.len();
|
||||
}
|
||||
self.history_prev();
|
||||
None
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if self.history_index < self.history.len() - 1 {
|
||||
self.history_index += 1;
|
||||
self.input = self.history[self.history_index].clone();
|
||||
self.cursor_position = self.input.len();
|
||||
} else if self.history_index < self.history.len() {
|
||||
self.history_index = self.history.len();
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
self.history_next();
|
||||
None
|
||||
}
|
||||
_ => {}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Handle keys in command mode
|
||||
fn handle_command_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = VimMode::Normal;
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let command = self.input.clone();
|
||||
self.mode = VimMode::Normal;
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
if !command.trim().is_empty() {
|
||||
return Some(InputEvent::Command(command));
|
||||
}
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input.insert(self.cursor_position, c);
|
||||
self.cursor_position += 1;
|
||||
None
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.cursor_position > 0 {
|
||||
self.input.remove(self.cursor_position - 1);
|
||||
self.cursor_position -= 1;
|
||||
} else {
|
||||
// Empty command, exit to normal mode
|
||||
self.mode = VimMode::Normal;
|
||||
return Some(InputEvent::ModeChange(VimMode::Normal));
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.cursor_position = self.cursor_position.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle keys in visual mode (simplified)
|
||||
fn handle_visual_mode(&mut self, key: KeyEvent) -> Option<InputEvent> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.mode = VimMode::Normal;
|
||||
Some(InputEvent::ModeChange(VimMode::Normal))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// History navigation - previous
|
||||
fn history_prev(&mut self) {
|
||||
if !self.history.is_empty() && self.history_index > 0 {
|
||||
self.history_index -= 1;
|
||||
self.input = self.history[self.history_index].clone();
|
||||
self.cursor_position = self.input.len();
|
||||
}
|
||||
}
|
||||
|
||||
/// History navigation - next
|
||||
fn history_next(&mut self) {
|
||||
if self.history_index < self.history.len().saturating_sub(1) {
|
||||
self.history_index += 1;
|
||||
self.input = self.history[self.history_index].clone();
|
||||
self.cursor_position = self.input.len();
|
||||
} else if self.history_index < self.history.len() {
|
||||
self.history_index = self.history.len();
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Find next word position
|
||||
fn next_word_position(&self) -> usize {
|
||||
let bytes = self.input.as_bytes();
|
||||
let mut pos = self.cursor_position;
|
||||
|
||||
// Skip current word
|
||||
while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
// Skip whitespace
|
||||
while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
|
||||
pos += 1;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// Find previous word position
|
||||
fn prev_word_position(&self) -> usize {
|
||||
let bytes = self.input.as_bytes();
|
||||
let mut pos = self.cursor_position.saturating_sub(1);
|
||||
|
||||
// Skip whitespace
|
||||
while pos > 0 && bytes[pos].is_ascii_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
// Skip to start of word
|
||||
while pos > 0 && !bytes[pos - 1].is_ascii_whitespace() {
|
||||
pos -= 1;
|
||||
}
|
||||
pos
|
||||
}
|
||||
|
||||
/// Render the borderless input (single line)
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let is_empty = self.input.is_empty();
|
||||
let symbols = &self.theme.symbols;
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(self.theme.border_active)
|
||||
.padding(Padding::horizontal(1))
|
||||
.title(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("✍", self.theme.border_active),
|
||||
Span::raw(" "),
|
||||
Span::styled("Input", self.theme.border_active),
|
||||
Span::raw(" "),
|
||||
]));
|
||||
|
||||
// Display input with cursor
|
||||
let (text_before, text_after) = if self.cursor_position < self.input.len() {
|
||||
(
|
||||
&self.input[..self.cursor_position],
|
||||
&self.input[self.cursor_position..],
|
||||
)
|
||||
} else {
|
||||
(&self.input[..], "")
|
||||
// Mode-specific prefix
|
||||
let prefix = match self.mode {
|
||||
VimMode::Normal => Span::styled(
|
||||
format!("{} ", symbols.mode_normal),
|
||||
self.theme.status_dim,
|
||||
),
|
||||
VimMode::Insert => Span::styled(
|
||||
format!("{} ", symbols.user_prefix),
|
||||
self.theme.input_prefix,
|
||||
),
|
||||
VimMode::Command => Span::styled(
|
||||
": ",
|
||||
self.theme.input_prefix,
|
||||
),
|
||||
VimMode::Visual => Span::styled(
|
||||
format!("{} ", symbols.mode_visual),
|
||||
self.theme.status_accent,
|
||||
),
|
||||
};
|
||||
|
||||
let line = if is_empty {
|
||||
// Cursor position handling
|
||||
let (text_before, cursor_char, text_after) = if self.cursor_position < self.input.len() {
|
||||
let before = &self.input[..self.cursor_position];
|
||||
let cursor = &self.input[self.cursor_position..self.cursor_position + 1];
|
||||
let after = &self.input[self.cursor_position + 1..];
|
||||
(before, cursor, after)
|
||||
} else {
|
||||
(&self.input[..], " ", "")
|
||||
};
|
||||
|
||||
let line = if is_empty && self.mode == VimMode::Insert {
|
||||
Line::from(vec![
|
||||
Span::styled("❯ ", self.theme.input_box_active),
|
||||
Span::styled("▊", self.theme.input_box_active),
|
||||
Span::styled(" Type a message...", Style::default().fg(self.theme.palette.fg_dim)),
|
||||
Span::raw(" "),
|
||||
prefix,
|
||||
Span::styled("▊", self.theme.input_prefix),
|
||||
Span::styled(" Type message...", self.theme.input_placeholder),
|
||||
])
|
||||
} else if is_empty && self.mode == VimMode::Command {
|
||||
Line::from(vec![
|
||||
Span::raw(" "),
|
||||
prefix,
|
||||
Span::styled("▊", self.theme.input_prefix),
|
||||
])
|
||||
} else {
|
||||
// Build cursor span with appropriate styling
|
||||
let cursor_style = if self.mode == VimMode::Normal {
|
||||
Style::default()
|
||||
.bg(self.theme.palette.fg)
|
||||
.fg(self.theme.palette.bg)
|
||||
} else {
|
||||
self.theme.input_prefix
|
||||
};
|
||||
|
||||
let cursor_span = if self.mode == VimMode::Normal && !is_empty {
|
||||
Span::styled(cursor_char.to_string(), cursor_style)
|
||||
} else {
|
||||
Span::styled("▊", self.theme.input_prefix)
|
||||
};
|
||||
|
||||
Line::from(vec![
|
||||
Span::styled("❯ ", self.theme.input_box_active),
|
||||
Span::styled(text_before, self.theme.input_box),
|
||||
Span::styled("▊", self.theme.input_box_active),
|
||||
Span::styled(text_after, self.theme.input_box),
|
||||
Span::raw(" "),
|
||||
prefix,
|
||||
Span::styled(text_before.to_string(), self.theme.input_text),
|
||||
cursor_span,
|
||||
Span::styled(text_after.to_string(), self.theme.input_text),
|
||||
])
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(line).block(block);
|
||||
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
/// Clear input
|
||||
pub fn clear(&mut self) {
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
|
||||
/// Get current input text
|
||||
pub fn text(&self) -> &str {
|
||||
&self.input
|
||||
}
|
||||
|
||||
/// Set input text
|
||||
pub fn set_text(&mut self, text: String) {
|
||||
self.input = text;
|
||||
self.cursor_position = self.input.len();
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_mode_transitions() {
|
||||
let theme = Theme::default();
|
||||
let mut input = InputBox::new(theme);
|
||||
|
||||
// Start in insert mode
|
||||
assert_eq!(input.mode(), VimMode::Insert);
|
||||
|
||||
// Escape to normal mode
|
||||
let event = input.handle_key(KeyEvent::from(KeyCode::Esc));
|
||||
assert!(matches!(event, Some(InputEvent::ModeChange(VimMode::Normal))));
|
||||
assert_eq!(input.mode(), VimMode::Normal);
|
||||
|
||||
// 'i' to insert mode
|
||||
let event = input.handle_key(KeyEvent::from(KeyCode::Char('i')));
|
||||
assert!(matches!(event, Some(InputEvent::ModeChange(VimMode::Insert))));
|
||||
assert_eq!(input.mode(), VimMode::Insert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_insert_text() {
|
||||
let theme = Theme::default();
|
||||
let mut input = InputBox::new(theme);
|
||||
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('h')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('i')));
|
||||
|
||||
assert_eq!(input.text(), "hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_command_mode() {
|
||||
let theme = Theme::default();
|
||||
let mut input = InputBox::new(theme);
|
||||
|
||||
// Escape to normal, then : to command
|
||||
input.handle_key(KeyEvent::from(KeyCode::Esc));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char(':')));
|
||||
|
||||
assert_eq!(input.mode(), VimMode::Command);
|
||||
|
||||
// Type command
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('q')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('u')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('i')));
|
||||
input.handle_key(KeyEvent::from(KeyCode::Char('t')));
|
||||
|
||||
assert_eq!(input.text(), "quit");
|
||||
|
||||
// Submit command
|
||||
let event = input.handle_key(KeyEvent::from(KeyCode::Enter));
|
||||
assert!(matches!(event, Some(InputEvent::Command(cmd)) if cmd == "quit"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! TUI components for the borderless multi-provider design
|
||||
|
||||
mod chat_panel;
|
||||
mod input_box;
|
||||
mod permission_popup;
|
||||
mod provider_tabs;
|
||||
mod status_bar;
|
||||
|
||||
pub use chat_panel::{ChatMessage, ChatPanel};
|
||||
pub use input_box::InputBox;
|
||||
pub use chat_panel::{ChatMessage, ChatPanel, DisplayMessage};
|
||||
pub use input_box::{InputBox, InputEvent};
|
||||
pub use permission_popup::{PermissionOption, PermissionPopup};
|
||||
pub use status_bar::StatusBar;
|
||||
pub use provider_tabs::ProviderTabs;
|
||||
pub use status_bar::{AppState, StatusBar};
|
||||
|
||||
@@ -136,7 +136,7 @@ impl PermissionPopup {
|
||||
// Separator
|
||||
let separator = Line::styled(
|
||||
"─".repeat(sections[2].width as usize),
|
||||
Style::default().fg(self.theme.palette.border),
|
||||
Style::default().fg(self.theme.palette.divider_fg),
|
||||
);
|
||||
frame.render_widget(Paragraph::new(separator), sections[2]);
|
||||
|
||||
|
||||
189
crates/app/ui/src/components/provider_tabs.rs
Normal file
189
crates/app/ui/src/components/provider_tabs.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
//! Provider tabs component for multi-LLM support
|
||||
//!
|
||||
//! Displays horizontal tabs for switching between providers (Claude, Ollama, OpenAI)
|
||||
//! with icons and keybind hints.
|
||||
|
||||
use crate::theme::{Provider, Theme};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Provider tab state and rendering
|
||||
pub struct ProviderTabs {
|
||||
active: Provider,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl ProviderTabs {
|
||||
/// Create new provider tabs with default provider
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
active: Provider::Ollama, // Default to Ollama (local)
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with specific active provider
|
||||
pub fn with_provider(provider: Provider, theme: Theme) -> Self {
|
||||
Self {
|
||||
active: provider,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the currently active provider
|
||||
pub fn active(&self) -> Provider {
|
||||
self.active
|
||||
}
|
||||
|
||||
/// Set the active provider
|
||||
pub fn set_active(&mut self, provider: Provider) {
|
||||
self.active = provider;
|
||||
}
|
||||
|
||||
/// Cycle to the next provider
|
||||
pub fn next(&mut self) {
|
||||
self.active = match self.active {
|
||||
Provider::Claude => Provider::Ollama,
|
||||
Provider::Ollama => Provider::OpenAI,
|
||||
Provider::OpenAI => Provider::Claude,
|
||||
};
|
||||
}
|
||||
|
||||
/// Cycle to the previous provider
|
||||
pub fn previous(&mut self) {
|
||||
self.active = match self.active {
|
||||
Provider::Claude => Provider::OpenAI,
|
||||
Provider::Ollama => Provider::Claude,
|
||||
Provider::OpenAI => Provider::Ollama,
|
||||
};
|
||||
}
|
||||
|
||||
/// Select provider by number (1, 2, 3)
|
||||
pub fn select_by_number(&mut self, num: u8) {
|
||||
self.active = match num {
|
||||
1 => Provider::Claude,
|
||||
2 => Provider::Ollama,
|
||||
3 => Provider::OpenAI,
|
||||
_ => self.active,
|
||||
};
|
||||
}
|
||||
|
||||
/// Update the theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
/// Render the provider tabs (borderless)
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
// Add spacing at start
|
||||
spans.push(Span::raw(" "));
|
||||
|
||||
for (i, provider) in Provider::all().iter().enumerate() {
|
||||
let is_active = *provider == self.active;
|
||||
let icon = self.theme.provider_icon(*provider);
|
||||
let name = provider.name();
|
||||
let number = (i + 1).to_string();
|
||||
|
||||
// Keybind hint
|
||||
spans.push(Span::styled(
|
||||
format!("[{}] ", number),
|
||||
self.theme.status_dim,
|
||||
));
|
||||
|
||||
// Icon and name
|
||||
let style = if is_active {
|
||||
Style::default()
|
||||
.fg(self.theme.provider_color(*provider))
|
||||
.add_modifier(ratatui::style::Modifier::BOLD)
|
||||
} else {
|
||||
self.theme.tab_inactive
|
||||
};
|
||||
|
||||
spans.push(Span::styled(format!("{} ", icon), style));
|
||||
spans.push(Span::styled(name.to_string(), style));
|
||||
|
||||
// Separator between tabs (not after last)
|
||||
if i < Provider::all().len() - 1 {
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", self.theme.symbols.vertical_separator),
|
||||
self.theme.status_dim,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Tab cycling hint on the right
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled("[Tab] cycle", self.theme.status_dim));
|
||||
|
||||
let line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
/// Render a compact version (just active provider)
|
||||
pub fn render_compact(&self, frame: &mut Frame, area: Rect) {
|
||||
let icon = self.theme.provider_icon(self.active);
|
||||
let name = self.active.name();
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{} {}", icon, name),
|
||||
Style::default()
|
||||
.fg(self.theme.provider_color(self.active))
|
||||
.add_modifier(ratatui::style::Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
|
||||
let paragraph = Paragraph::new(line);
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_provider_cycling() {
|
||||
let theme = Theme::default();
|
||||
let mut tabs = ProviderTabs::new(theme);
|
||||
|
||||
assert_eq!(tabs.active(), Provider::Ollama);
|
||||
|
||||
tabs.next();
|
||||
assert_eq!(tabs.active(), Provider::OpenAI);
|
||||
|
||||
tabs.next();
|
||||
assert_eq!(tabs.active(), Provider::Claude);
|
||||
|
||||
tabs.next();
|
||||
assert_eq!(tabs.active(), Provider::Ollama);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_by_number() {
|
||||
let theme = Theme::default();
|
||||
let mut tabs = ProviderTabs::new(theme);
|
||||
|
||||
tabs.select_by_number(1);
|
||||
assert_eq!(tabs.active(), Provider::Claude);
|
||||
|
||||
tabs.select_by_number(2);
|
||||
assert_eq!(tabs.active(), Provider::Ollama);
|
||||
|
||||
tabs.select_by_number(3);
|
||||
assert_eq!(tabs.active(), Provider::OpenAI);
|
||||
|
||||
// Invalid number should not change
|
||||
tabs.select_by_number(4);
|
||||
assert_eq!(tabs.active(), Provider::OpenAI);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
use crate::theme::Theme;
|
||||
//! Multi-provider status bar component
|
||||
//!
|
||||
//! Borderless status bar showing provider, model, mode, stats, and state.
|
||||
//! Format: model │ Mode │ N msgs │ N │ ~Nk │ $0.00 │ ● status
|
||||
|
||||
use crate::theme::{Provider, Theme, VimMode};
|
||||
use agent_core::SessionStats;
|
||||
use permissions::Mode;
|
||||
use ratatui::{
|
||||
@@ -8,102 +13,221 @@ use ratatui::{
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Application state for status display
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AppState {
|
||||
Idle,
|
||||
Streaming,
|
||||
WaitingPermission,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
AppState::Idle => "○",
|
||||
AppState::Streaming => "●",
|
||||
AppState::WaitingPermission => "◐",
|
||||
AppState::Error => "✗",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
AppState::Idle => "idle",
|
||||
AppState::Streaming => "streaming",
|
||||
AppState::WaitingPermission => "waiting",
|
||||
AppState::Error => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StatusBar {
|
||||
provider: Provider,
|
||||
model: String,
|
||||
mode: Mode,
|
||||
vim_mode: VimMode,
|
||||
stats: SessionStats,
|
||||
last_tool: Option<String>,
|
||||
state: AppState,
|
||||
estimated_cost: f64,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl StatusBar {
|
||||
pub fn new(model: String, mode: Mode, theme: Theme) -> Self {
|
||||
Self {
|
||||
provider: Provider::Ollama, // Default provider
|
||||
model,
|
||||
mode,
|
||||
vim_mode: VimMode::Insert,
|
||||
stats: SessionStats::new(),
|
||||
last_tool: None,
|
||||
state: AppState::Idle,
|
||||
estimated_cost: 0.0,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the active provider
|
||||
pub fn set_provider(&mut self, provider: Provider) {
|
||||
self.provider = provider;
|
||||
}
|
||||
|
||||
/// Set the current model
|
||||
pub fn set_model(&mut self, model: String) {
|
||||
self.model = model;
|
||||
}
|
||||
|
||||
/// Update session stats
|
||||
pub fn update_stats(&mut self, stats: SessionStats) {
|
||||
self.stats = stats;
|
||||
}
|
||||
|
||||
/// Set the last used tool
|
||||
pub fn set_last_tool(&mut self, tool: String) {
|
||||
self.last_tool = Some(tool);
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let elapsed = self.stats.start_time.elapsed().unwrap_or_default();
|
||||
let elapsed_str = SessionStats::format_duration(elapsed);
|
||||
/// Set application state
|
||||
pub fn set_state(&mut self, state: AppState) {
|
||||
self.state = state;
|
||||
}
|
||||
|
||||
let (mode_str, mode_icon) = match self.mode {
|
||||
Mode::Plan => ("Plan", "🔍"),
|
||||
Mode::AcceptEdits => ("AcceptEdits", "✏️"),
|
||||
Mode::Code => ("Code", "⚡"),
|
||||
/// Set vim mode for display
|
||||
pub fn set_vim_mode(&mut self, mode: VimMode) {
|
||||
self.vim_mode = mode;
|
||||
}
|
||||
|
||||
/// Add to estimated cost
|
||||
pub fn add_cost(&mut self, cost: f64) {
|
||||
self.estimated_cost += cost;
|
||||
}
|
||||
|
||||
/// Reset cost
|
||||
pub fn reset_cost(&mut self) {
|
||||
self.estimated_cost = 0.0;
|
||||
}
|
||||
|
||||
/// Update theme
|
||||
pub fn set_theme(&mut self, theme: Theme) {
|
||||
self.theme = theme;
|
||||
}
|
||||
|
||||
/// Render the status bar
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let symbols = &self.theme.symbols;
|
||||
let sep = symbols.vertical_separator;
|
||||
|
||||
// Provider icon and model
|
||||
let provider_icon = self.theme.provider_icon(self.provider);
|
||||
let provider_style = ratatui::style::Style::default()
|
||||
.fg(self.theme.provider_color(self.provider));
|
||||
|
||||
// Permission mode
|
||||
let mode_str = match self.mode {
|
||||
Mode::Plan => "Plan",
|
||||
Mode::AcceptEdits => "Edit",
|
||||
Mode::Code => "Code",
|
||||
};
|
||||
|
||||
let last_tool_str = self
|
||||
.last_tool
|
||||
.as_ref()
|
||||
.map(|t| format!("● {}", t))
|
||||
.unwrap_or_else(|| "○ idle".to_string());
|
||||
// Format token count
|
||||
let tokens_str = if self.stats.estimated_tokens >= 1000 {
|
||||
format!("~{}k", self.stats.estimated_tokens / 1000)
|
||||
} else {
|
||||
format!("~{}", self.stats.estimated_tokens)
|
||||
};
|
||||
|
||||
// Build status line with colorful sections
|
||||
let separator_style = self.theme.status_bar;
|
||||
// Cost display (only for paid providers)
|
||||
let cost_str = if self.provider != Provider::Ollama && self.estimated_cost > 0.0 {
|
||||
format!("${:.2}", self.estimated_cost)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// State indicator
|
||||
let state_style = match self.state {
|
||||
AppState::Idle => self.theme.status_dim,
|
||||
AppState::Streaming => ratatui::style::Style::default()
|
||||
.fg(self.theme.palette.success),
|
||||
AppState::WaitingPermission => ratatui::style::Style::default()
|
||||
.fg(self.theme.palette.warning),
|
||||
AppState::Error => ratatui::style::Style::default()
|
||||
.fg(self.theme.palette.error),
|
||||
};
|
||||
|
||||
// Build status line
|
||||
let mut spans = vec![
|
||||
Span::styled(" ", separator_style),
|
||||
Span::styled(mode_icon, self.theme.status_bar),
|
||||
Span::styled(" ", separator_style),
|
||||
Span::styled(mode_str, self.theme.status_bar),
|
||||
Span::styled(" │ ", separator_style),
|
||||
Span::styled("⚙", self.theme.status_bar),
|
||||
Span::styled(" ", separator_style),
|
||||
Span::styled(" ", self.theme.status_bar),
|
||||
// Provider icon and model
|
||||
Span::styled(format!("{} ", provider_icon), provider_style),
|
||||
Span::styled(&self.model, self.theme.status_bar),
|
||||
Span::styled(" │ ", separator_style),
|
||||
Span::styled(
|
||||
format!("{} msgs", self.stats.total_messages),
|
||||
self.theme.status_bar,
|
||||
),
|
||||
Span::styled(" │ ", separator_style),
|
||||
Span::styled(
|
||||
format!("{} tools", self.stats.total_tool_calls),
|
||||
self.theme.status_bar,
|
||||
),
|
||||
Span::styled(" │ ", separator_style),
|
||||
Span::styled(
|
||||
format!("~{} tok", self.stats.estimated_tokens),
|
||||
self.theme.status_bar,
|
||||
),
|
||||
Span::styled(" │ ", separator_style),
|
||||
Span::styled("⏱", self.theme.status_bar),
|
||||
Span::styled(" ", separator_style),
|
||||
Span::styled(elapsed_str, self.theme.status_bar),
|
||||
Span::styled(" │ ", separator_style),
|
||||
Span::styled(last_tool_str, self.theme.status_bar),
|
||||
Span::styled(format!(" {} ", sep), self.theme.status_dim),
|
||||
// Permission mode
|
||||
Span::styled(mode_str, self.theme.status_bar),
|
||||
Span::styled(format!(" {} ", sep), self.theme.status_dim),
|
||||
// Message count
|
||||
Span::styled(format!("{} msgs", self.stats.total_messages), self.theme.status_bar),
|
||||
Span::styled(format!(" {} ", sep), self.theme.status_dim),
|
||||
// Tool count
|
||||
Span::styled(format!("{} {}", symbols.tool_prefix, self.stats.total_tool_calls), self.theme.status_bar),
|
||||
Span::styled(format!(" {} ", sep), self.theme.status_dim),
|
||||
// Token count
|
||||
Span::styled(tokens_str, self.theme.status_bar),
|
||||
];
|
||||
|
||||
// Add help text on the right
|
||||
let help_text = " ? /help ";
|
||||
// Add cost if applicable
|
||||
if !cost_str.is_empty() {
|
||||
spans.push(Span::styled(format!(" {} ", sep), self.theme.status_dim));
|
||||
spans.push(Span::styled(cost_str, self.theme.status_accent));
|
||||
}
|
||||
|
||||
// Calculate current length
|
||||
let current_len: usize = spans.iter()
|
||||
// State indicator
|
||||
spans.push(Span::styled(format!(" {} ", sep), self.theme.status_dim));
|
||||
spans.push(Span::styled(
|
||||
format!("{} {}", self.state.icon(), self.state.label()),
|
||||
state_style,
|
||||
));
|
||||
|
||||
// Calculate current width
|
||||
let current_width: usize = spans
|
||||
.iter()
|
||||
.map(|s| unicode_width::UnicodeWidthStr::width(s.content.as_ref()))
|
||||
.sum();
|
||||
|
||||
// Add padding
|
||||
let padding = area
|
||||
.width
|
||||
.saturating_sub((current_len + help_text.len()) as u16);
|
||||
// Add help hint on the right
|
||||
let vim_indicator = self.vim_mode.indicator(&self.theme.symbols);
|
||||
let help_hint = format!("{} ?", vim_indicator);
|
||||
let help_width = unicode_width::UnicodeWidthStr::width(help_hint.as_str()) + 2;
|
||||
|
||||
spans.push(Span::styled(" ".repeat(padding as usize), separator_style));
|
||||
spans.push(Span::styled(help_text, self.theme.status_bar));
|
||||
// Padding
|
||||
let available = area.width as usize;
|
||||
let padding = available.saturating_sub(current_width + help_width);
|
||||
spans.push(Span::raw(" ".repeat(padding)));
|
||||
spans.push(Span::styled(help_hint, self.theme.status_dim));
|
||||
spans.push(Span::raw(" "));
|
||||
|
||||
let line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(line);
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_status_bar_creation() {
|
||||
let theme = Theme::default();
|
||||
let status_bar = StatusBar::new("gpt-4".to_string(), Mode::Plan, theme);
|
||||
assert_eq!(status_bar.model, "gpt-4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_app_state_display() {
|
||||
assert_eq!(AppState::Idle.label(), "idle");
|
||||
assert_eq!(AppState::Streaming.label(), "streaming");
|
||||
assert_eq!(AppState::Error.icon(), "✗");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user