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