feat(ui): add TUI with streaming agent integration and theming
Add a new terminal UI crate (crates/app/ui) built with ratatui providing an interactive chat interface with real-time LLM streaming and tool visualization. Features: - Chat panel with horizontal padding for improved readability - Input box with cursor navigation and command history - Status bar with session statistics and uniform background styling - 7 theme presets: Tokyo Night (default), Dracula, Catppuccin, Nord, Synthwave, Rose Pine, and Midnight Ocean - Theme switching via /theme <name> and /themes commands - Streaming LLM responses that accumulate into single messages - Real-time tool call visualization with success/error states - Session tracking (messages, tokens, tool calls, duration) - REPL commands: /help, /status, /cost, /checkpoint, /rewind, /clear, /exit Integration: - CLI automatically launches TUI mode when running interactively (no prompt) - Falls back to legacy text REPL with --no-tui flag - Uses existing agent loop with streaming support - Supports all existing tools (read, write, edit, glob, grep, bash) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
191
crates/app/ui/src/components/chat_panel.rs
Normal file
191
crates/app/ui/src/components/chat_panel.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use crate::theme::Theme;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
|
||||
Frame,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChatMessage {
|
||||
User(String),
|
||||
Assistant(String),
|
||||
ToolCall { name: String, args: String },
|
||||
ToolResult { success: bool, output: String },
|
||||
System(String),
|
||||
}
|
||||
|
||||
pub struct ChatPanel {
|
||||
messages: Vec<ChatMessage>,
|
||||
scroll_offset: usize,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl ChatPanel {
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_message(&mut self, message: ChatMessage) {
|
||||
self.messages.push(message);
|
||||
// Auto-scroll to bottom on new message
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
|
||||
/// Append content to the last assistant message, or create a new one if none exists
|
||||
pub fn append_to_assistant(&mut self, content: &str) {
|
||||
if let Some(ChatMessage::Assistant(last_content)) = self.messages.last_mut() {
|
||||
last_content.push_str(content);
|
||||
} else {
|
||||
self.messages.push(ChatMessage::Assistant(content.to_string()));
|
||||
}
|
||||
// Auto-scroll to bottom on update
|
||||
self.scroll_to_bottom();
|
||||
}
|
||||
|
||||
pub fn scroll_up(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn scroll_down(&mut self) {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
||||
}
|
||||
|
||||
pub fn scroll_to_bottom(&mut self) {
|
||||
self.scroll_offset = self.messages.len().saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let mut text_lines = Vec::new();
|
||||
|
||||
for message in &self.messages {
|
||||
match 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(""));
|
||||
}
|
||||
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),
|
||||
]));
|
||||
} else {
|
||||
text_lines.push(Line::styled(
|
||||
format!(" {}", line),
|
||||
self.theme.assistant_message,
|
||||
));
|
||||
}
|
||||
}
|
||||
text_lines.push(Line::from(""));
|
||||
}
|
||||
ChatMessage::ToolCall { name, args } => {
|
||||
text_lines.push(Line::from(vec![
|
||||
Span::styled(" ⚡ ", self.theme.tool_call),
|
||||
Span::styled(
|
||||
format!("{} ", name),
|
||||
self.theme.tool_call,
|
||||
),
|
||||
Span::styled(
|
||||
args,
|
||||
self.theme.tool_call.add_modifier(Modifier::DIM),
|
||||
),
|
||||
]));
|
||||
}
|
||||
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])
|
||||
} else {
|
||||
output.clone()
|
||||
};
|
||||
|
||||
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(
|
||||
content,
|
||||
Style::default().fg(self.theme.palette.fg_dim),
|
||||
),
|
||||
]));
|
||||
text_lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
|
||||
// Render scrollbar if needed
|
||||
if self.messages.len() > 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);
|
||||
|
||||
let mut scrollbar_state = ScrollbarState::default()
|
||||
.content_length(self.messages.len())
|
||||
.position(self.scroll_offset);
|
||||
|
||||
frame.render_stateful_widget(
|
||||
scrollbar,
|
||||
area,
|
||||
&mut scrollbar_state,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn messages(&self) -> &[ChatMessage] {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
}
|
||||
144
crates/app/ui/src/components/input_box.rs
Normal file
144
crates/app/ui/src/components/input_box.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use crate::theme::Theme;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Padding, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub struct InputBox {
|
||||
input: String,
|
||||
cursor_position: usize,
|
||||
history: Vec<String>,
|
||||
history_index: usize,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl InputBox {
|
||||
pub fn new(theme: Theme) -> Self {
|
||||
Self {
|
||||
input: String::new(),
|
||||
cursor_position: 0,
|
||||
history: Vec::new(),
|
||||
history_index: 0,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Option<String> {
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
let message = self.input.clone();
|
||||
if !message.trim().is_empty() {
|
||||
self.history.push(message.clone());
|
||||
self.history_index = self.history.len();
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
return Some(message);
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.input.insert(self.cursor_position, c);
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.cursor_position > 0 {
|
||||
self.input.remove(self.cursor_position - 1);
|
||||
self.cursor_position -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.input.remove(self.cursor_position);
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.cursor_position = self.cursor_position.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if self.cursor_position < self.input.len() {
|
||||
self.cursor_position += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.cursor_position = self.input.len();
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
let is_empty = self.input.is_empty();
|
||||
|
||||
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[..], "")
|
||||
};
|
||||
|
||||
let line = if is_empty {
|
||||
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)),
|
||||
])
|
||||
} else {
|
||||
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),
|
||||
])
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(line).block(block);
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.input.clear();
|
||||
self.cursor_position = 0;
|
||||
}
|
||||
}
|
||||
9
crates/app/ui/src/components/mod.rs
Normal file
9
crates/app/ui/src/components/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
mod chat_panel;
|
||||
mod input_box;
|
||||
mod permission_popup;
|
||||
mod status_bar;
|
||||
|
||||
pub use chat_panel::{ChatMessage, ChatPanel};
|
||||
pub use input_box::InputBox;
|
||||
pub use permission_popup::{PermissionOption, PermissionPopup};
|
||||
pub use status_bar::StatusBar;
|
||||
196
crates/app/ui/src/components/permission_popup.rs
Normal file
196
crates/app/ui/src/components/permission_popup.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use crate::theme::Theme;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use permissions::PermissionDecision;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PermissionOption {
|
||||
AllowOnce,
|
||||
AlwaysAllow,
|
||||
Deny,
|
||||
Explain,
|
||||
}
|
||||
|
||||
pub struct PermissionPopup {
|
||||
tool: String,
|
||||
context: Option<String>,
|
||||
selected: usize,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl PermissionPopup {
|
||||
pub fn new(tool: String, context: Option<String>, theme: Theme) -> Self {
|
||||
Self {
|
||||
tool,
|
||||
context,
|
||||
selected: 0,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> Option<PermissionOption> {
|
||||
match key.code {
|
||||
KeyCode::Char('a') => Some(PermissionOption::AllowOnce),
|
||||
KeyCode::Char('A') => Some(PermissionOption::AlwaysAllow),
|
||||
KeyCode::Char('d') => Some(PermissionOption::Deny),
|
||||
KeyCode::Char('?') => Some(PermissionOption::Explain),
|
||||
KeyCode::Up => {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if self.selected < 3 {
|
||||
self.selected += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
KeyCode::Enter => match self.selected {
|
||||
0 => Some(PermissionOption::AllowOnce),
|
||||
1 => Some(PermissionOption::AlwaysAllow),
|
||||
2 => Some(PermissionOption::Deny),
|
||||
3 => Some(PermissionOption::Explain),
|
||||
_ => None,
|
||||
},
|
||||
KeyCode::Esc => Some(PermissionOption::Deny),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect) {
|
||||
// Center the popup
|
||||
let popup_area = crate::layout::AppLayout::center_popup(area, 64, 14);
|
||||
|
||||
// Clear the area behind the popup
|
||||
frame.render_widget(Clear, popup_area);
|
||||
|
||||
// Render popup with styled border
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(self.theme.popup_border)
|
||||
.style(self.theme.popup_bg)
|
||||
.title(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled("🔒", self.theme.popup_title),
|
||||
Span::raw(" "),
|
||||
Span::styled("Permission Required", self.theme.popup_title),
|
||||
Span::raw(" "),
|
||||
]));
|
||||
|
||||
frame.render_widget(block, popup_area);
|
||||
|
||||
// Split popup into sections
|
||||
let inner = popup_area.inner(ratatui::layout::Margin {
|
||||
vertical: 1,
|
||||
horizontal: 2,
|
||||
});
|
||||
|
||||
let sections = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(2), // Tool name with box
|
||||
Constraint::Length(3), // Context (if any)
|
||||
Constraint::Length(1), // Separator
|
||||
Constraint::Length(1), // Option 1
|
||||
Constraint::Length(1), // Option 2
|
||||
Constraint::Length(1), // Option 3
|
||||
Constraint::Length(1), // Option 4
|
||||
Constraint::Length(1), // Help text
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
// Tool name with highlight
|
||||
let tool_line = Line::from(vec![
|
||||
Span::styled("⚡ Tool: ", Style::default().fg(self.theme.palette.warning)),
|
||||
Span::styled(&self.tool, self.theme.popup_title),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(tool_line), sections[0]);
|
||||
|
||||
// Context with wrapping
|
||||
if let Some(ctx) = &self.context {
|
||||
let context_text = if ctx.len() > 100 {
|
||||
format!("{}...", &ctx[..100])
|
||||
} else {
|
||||
ctx.clone()
|
||||
};
|
||||
let context_lines = textwrap::wrap(&context_text, (sections[1].width - 2) as usize);
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled("📝 Context: ", Style::default().fg(self.theme.palette.info)),
|
||||
])
|
||||
];
|
||||
for line in context_lines.iter().take(2) {
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(line.to_string(), Style::default().fg(self.theme.palette.fg_dim)),
|
||||
]));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(lines), sections[1]);
|
||||
}
|
||||
|
||||
// Separator
|
||||
let separator = Line::styled(
|
||||
"─".repeat(sections[2].width as usize),
|
||||
Style::default().fg(self.theme.palette.border),
|
||||
);
|
||||
frame.render_widget(Paragraph::new(separator), sections[2]);
|
||||
|
||||
// Options with icons and colors
|
||||
let options = [
|
||||
("✓", " [a] Allow once", self.theme.palette.success, 0),
|
||||
("✓✓", " [A] Always allow", self.theme.palette.primary, 1),
|
||||
("✗", " [d] Deny", self.theme.palette.error, 2),
|
||||
("?", " [?] Explain", self.theme.palette.info, 3),
|
||||
];
|
||||
|
||||
for (icon, text, color, idx) in options.iter() {
|
||||
let (style, prefix) = if self.selected == *idx {
|
||||
(
|
||||
self.theme.selected,
|
||||
"▶ "
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(*color),
|
||||
" "
|
||||
)
|
||||
};
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled(prefix, style),
|
||||
Span::styled(*icon, style),
|
||||
Span::styled(*text, style),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), sections[3 + idx]);
|
||||
}
|
||||
|
||||
// Help text at bottom
|
||||
let help_line = Line::from(vec![
|
||||
Span::styled(
|
||||
"↑↓ Navigate Enter to select Esc to deny",
|
||||
Style::default().fg(self.theme.palette.fg_dim).add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(help_line), sections[7]);
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionOption {
|
||||
pub fn to_decision(&self) -> Option<PermissionDecision> {
|
||||
match self {
|
||||
PermissionOption::AllowOnce => Some(PermissionDecision::Allow),
|
||||
PermissionOption::AlwaysAllow => Some(PermissionDecision::Allow),
|
||||
PermissionOption::Deny => Some(PermissionDecision::Deny),
|
||||
PermissionOption::Explain => None, // Special handling needed
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_persist(&self) -> bool {
|
||||
matches!(self, PermissionOption::AlwaysAllow)
|
||||
}
|
||||
}
|
||||
109
crates/app/ui/src/components/status_bar.rs
Normal file
109
crates/app/ui/src/components/status_bar.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use crate::theme::Theme;
|
||||
use agent_core::SessionStats;
|
||||
use permissions::Mode;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub struct StatusBar {
|
||||
model: String,
|
||||
mode: Mode,
|
||||
stats: SessionStats,
|
||||
last_tool: Option<String>,
|
||||
theme: Theme,
|
||||
}
|
||||
|
||||
impl StatusBar {
|
||||
pub fn new(model: String, mode: Mode, theme: Theme) -> Self {
|
||||
Self {
|
||||
model,
|
||||
mode,
|
||||
stats: SessionStats::new(),
|
||||
last_tool: None,
|
||||
theme,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_stats(&mut self, stats: SessionStats) {
|
||||
self.stats = stats;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let (mode_str, mode_icon) = match self.mode {
|
||||
Mode::Plan => ("Plan", "🔍"),
|
||||
Mode::AcceptEdits => ("AcceptEdits", "✏️"),
|
||||
Mode::Code => ("Code", "⚡"),
|
||||
};
|
||||
|
||||
let last_tool_str = self
|
||||
.last_tool
|
||||
.as_ref()
|
||||
.map(|t| format!("● {}", t))
|
||||
.unwrap_or_else(|| "○ idle".to_string());
|
||||
|
||||
// Build status line with colorful sections
|
||||
let separator_style = self.theme.status_bar;
|
||||
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.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),
|
||||
];
|
||||
|
||||
// Add help text on the right
|
||||
let help_text = " ? /help ";
|
||||
|
||||
// Calculate current length
|
||||
let current_len: 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);
|
||||
|
||||
spans.push(Span::styled(" ".repeat(padding as usize), separator_style));
|
||||
spans.push(Span::styled(help_text, self.theme.status_bar));
|
||||
|
||||
let line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(line);
|
||||
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user