use crate::app::models::ModelManager;
use crate::app::state::{App, TaskStatus};
use crate::ui::styles::AppStyles;
use ratatui::{
layout::Alignment,
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Padding, Paragraph},
};
use std::time::Duration;
#[derive(Debug, PartialEq, Eq)]
enum MessageType {
User,
Tool,
Thinking,
Success,
Error,
Wait,
Debug,
Status,
Title,
Normal,
}
#[derive(Debug, PartialEq, Eq)]
enum IndicatorType {
InProgress,
Success,
#[allow(dead_code)]
Error,
#[allow(dead_code)]
Wait,
#[allow(dead_code)]
None,
}
pub fn get_animation_state(app: &App) -> (bool, bool) {
let animation_active = app.last_message_time.elapsed() < Duration::from_millis(1000);
let highlight_on =
animation_active && (std::time::Instant::now().elapsed().as_millis() % 500) < 300;
(animation_active, highlight_on)
}
pub fn process_messages(
messages: &[&String],
animation_state: (bool, bool),
debug_enabled: bool,
) -> Vec<Line<'static>> {
let (_animation_active, highlight_on) = animation_state;
let total_messages = messages.len();
let mut all_lines = Vec::new();
for (idx, message) in messages.iter().enumerate() {
let message_type = detect_message_type(message);
match message_type {
MessageType::User => {
add_user_message_lines(&mut all_lines, message, idx, total_messages, highlight_on)
}
MessageType::Debug => {
if debug_enabled {
all_lines.push(Line::from(vec![Span::styled(
message.to_string(),
Style::default().fg(Color::Yellow),
)]));
}
}
_ => {
all_lines.push(format_message(
message,
idx,
total_messages,
highlight_on,
debug_enabled,
));
}
}
}
all_lines
}
fn add_user_message_lines(
lines: &mut Vec<Line<'static>>,
message: &str,
_idx: usize,
_total_messages: usize,
_highlight_on: bool,
) {
if let Some(stripped) = message.strip_prefix("> ") {
if stripped.contains('\n') {
let message_lines: Vec<&str> = stripped.split('\n').collect();
lines.push(Line::from(vec![
Span::styled(
"YOU: ",
Style::default()
.fg(AppStyles::accent_color())
.add_modifier(Modifier::BOLD),
),
Span::styled(message_lines[0].to_string(), AppStyles::user_input()),
]));
for line in &message_lines[1..] {
lines.push(Line::from(vec![
Span::styled(
" ", Style::default()
.fg(AppStyles::accent_color())
.add_modifier(Modifier::BOLD),
),
Span::styled((*line).to_string(), AppStyles::user_input()),
]));
}
} else {
lines.push(Line::from(vec![
Span::styled(
"YOU: ",
Style::default()
.fg(Color::LightBlue)
.add_modifier(Modifier::BOLD),
),
Span::styled(stripped.to_string(), AppStyles::user_input()),
]));
}
}
}
fn detect_message_type(message: &str) -> MessageType {
if message.starts_with("> ") {
MessageType::User
} else if message.contains("\x1b[32m⏺\x1b[0m")
|| message.contains("\x1b[31m⏺\x1b[0m")
|| message.starts_with("⏺ ")
|| message.starts_with("[tool] ")
{
MessageType::Tool
} else if message.starts_with("[thinking]") || message == "Thinking..." {
MessageType::Thinking
} else if message.starts_with("[success] ") {
MessageType::Success
} else if message.starts_with("[error] ")
|| message.starts_with("Error:")
|| message.starts_with("ERROR:")
{
MessageType::Error
} else if message.starts_with("[wait]") {
MessageType::Wait
} else if message.starts_with("DEBUG:") {
MessageType::Debug
} else if message.starts_with("Status:") {
MessageType::Status
} else if message.starts_with("★") {
MessageType::Title
} else {
MessageType::Normal
}
}
pub fn format_message(
message: &str,
idx: usize,
total_messages: usize,
highlight_on: bool,
debug_enabled: bool,
) -> Line<'static> {
let is_newest_msg = idx == total_messages - 1;
let message_type = detect_message_type(message);
match message_type {
MessageType::Tool => format_tool_message(message, is_newest_msg, highlight_on),
MessageType::Thinking => format_thinking_message(message, is_newest_msg, highlight_on),
MessageType::Success => format_success_message(message),
MessageType::Wait => format_wait_message(message),
MessageType::Error => format_error_message(message),
MessageType::Debug => {
if debug_enabled {
Line::from(vec![Span::styled(
message.to_string(),
Style::default().fg(Color::Yellow),
)])
} else {
Line::from("")
}
}
MessageType::Status => Line::from(vec![Span::styled(
message.to_string(),
Style::default().fg(Color::Blue),
)]),
MessageType::Title => {
Line::from(vec![Span::styled(message.to_string(), AppStyles::title())])
}
MessageType::User => {
if let Some(stripped) = message.strip_prefix("> ") {
Line::from(vec![
Span::styled(
"YOU: ",
Style::default()
.fg(Color::LightBlue)
.add_modifier(Modifier::BOLD),
),
Span::styled(stripped.to_string(), AppStyles::user_input()),
])
} else {
Line::from(vec![Span::raw(message.to_string())])
}
}
MessageType::Normal => format_model_response(message),
}
}
fn format_tool_message(message: &str, is_newest_msg: bool, highlight_on: bool) -> Line<'static> {
let content = if message.contains("\x1b[32m⏺\x1b[0m") {
if let Some(ansi_end_pos) = message.find("\x1b[0m") {
let content_start = ansi_end_pos + 4; if content_start < message.len() {
message[content_start..].trim_start()
} else {
""
}
} else {
""
}
} else if let Some(content) = message.strip_prefix("[tool] ") {
content
} else if let Some(content) = message.strip_prefix("⏺ ") {
content
} else {
message
};
let is_completed = content.contains("Result:") || content.contains("completed");
let indicator_style = get_indicator_style(
if is_completed {
IndicatorType::Success
} else {
IndicatorType::InProgress
},
is_newest_msg,
highlight_on,
);
let text_style = Style::default()
.fg(Color::LightBlue)
.add_modifier(Modifier::BOLD);
Line::from(vec![
Span::styled("⏺ ", indicator_style),
Span::styled(content.to_string(), text_style),
])
}
fn format_thinking_message(
message: &str,
is_newest_msg: bool,
highlight_on: bool,
) -> Line<'static> {
let content = message
.strip_prefix("[thinking] ")
.or_else(|| message.strip_prefix("Thinking..."))
.unwrap_or(message)
.strip_prefix("⚪ ")
.or_else(|| message.strip_prefix("⏺ "))
.unwrap_or(message);
let text_style = if is_newest_msg && highlight_on {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::ITALIC)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::LightYellow)
.add_modifier(Modifier::ITALIC)
};
let indicator_style =
get_indicator_style(IndicatorType::InProgress, is_newest_msg, highlight_on);
Line::from(vec![
Span::styled("⏺ ", indicator_style),
Span::styled(content.to_string(), text_style),
])
}
fn format_success_message(message: &str) -> Line<'static> {
let content = if message.contains("\x1b[32m⏺\x1b[0m") {
if let Some(ansi_end_pos) = message.find("\x1b[0m") {
let content_start = ansi_end_pos + 4;
if content_start < message.len() {
message[content_start..].trim_start()
} else {
""
}
} else {
""
}
} else if let Some(content) = message.strip_prefix("[success] ") {
content
} else if let Some(content) = message.strip_prefix("⏺ ") {
content
} else {
message
};
Line::from(vec![
Span::styled("⏺ ", Style::default().fg(Color::Green)),
Span::styled(content.to_string(), Style::default().fg(Color::Green)),
])
}
fn format_wait_message(message: &str) -> Line<'static> {
let content = message
.strip_prefix("[wait] ")
.unwrap_or(message)
.strip_prefix("⚪ ")
.unwrap_or(message);
Line::from(vec![
Span::styled("⚪ ", Style::default().fg(Color::LightYellow)),
Span::styled(content.to_string(), Style::default().fg(Color::Yellow)),
])
}
fn format_error_message(message: &str) -> Line<'static> {
let content = if message.contains("\x1b[31m⏺\x1b[0m") {
if let Some(ansi_end_pos) = message.find("\x1b[0m") {
let content_start = ansi_end_pos + 4;
if content_start < message.len() {
message[content_start..].trim_start()
} else {
""
}
} else {
""
}
} else if let Some(content) = message.strip_prefix("[error] ") {
content
} else if let Some(content) = message.strip_prefix("Error:") {
content
} else if let Some(content) = message.strip_prefix("ERROR:") {
content
} else {
message
};
Line::from(vec![
Span::styled("⏺ ", AppStyles::error()),
Span::styled(content.to_string(), AppStyles::error()),
])
}
fn format_model_response(message: &str) -> Line<'static> {
if message.trim().is_empty() {
Line::from(" ") } else {
Line::from(vec![Span::raw(message.to_string())])
}
}
fn get_indicator_style(
indicator_type: IndicatorType,
is_newest: bool,
highlight_on: bool,
) -> Style {
match indicator_type {
IndicatorType::Success => Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
IndicatorType::Error => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
IndicatorType::Wait => Style::default().fg(Color::LightYellow),
IndicatorType::InProgress => {
if is_newest && highlight_on {
Style::default()
.fg(Color::Rgb(255, 165, 0)) .add_modifier(Modifier::BOLD)
.add_modifier(Modifier::SLOW_BLINK)
} else {
Style::default().fg(Color::Rgb(255, 165, 0))
}
}
IndicatorType::None => Style::default(),
}
}
pub fn apply_scrolling(
lines: &[Line<'static>],
scroll_position: usize,
visible_height: usize,
) -> (Vec<Line<'static>>, bool, bool) {
let total_lines = lines.len();
let visible_lines = if total_lines <= visible_height {
lines.to_vec()
} else {
lines
.iter()
.skip(scroll_position)
.take(visible_height)
.cloned()
.collect()
};
let has_more_above = scroll_position > 0;
let has_more_below = (scroll_position + visible_height) < total_lines;
(visible_lines, has_more_above, has_more_below)
}
pub fn create_scrollable_block(
title: &str,
has_more_above: bool,
has_more_below: bool,
title_style: Style,
) -> Block<'static> {
let block_title = if has_more_above && has_more_below {
Line::from(vec![
Span::styled(format!("{} ", title), title_style),
Span::styled("▲ more above ", AppStyles::hint()),
Span::styled("▼ more below", AppStyles::hint()),
])
} else if has_more_above {
Line::from(vec![
Span::styled(format!("{} ", title), title_style),
Span::styled("▲ more above", AppStyles::hint()),
])
} else if has_more_below {
Line::from(vec![
Span::styled(format!("{} ", title), title_style),
Span::styled("▼ more below", AppStyles::hint()),
])
} else {
Line::from(Span::styled(title.to_string(), title_style))
};
Block::default()
.borders(Borders::ALL)
.title(block_title)
.title_alignment(Alignment::Left)
.border_style(AppStyles::border())
.padding(Padding::new(1, 1, 0, 0))
}
pub fn create_empty_input_content(placeholder: &str) -> Text<'static> {
Text::from(vec![Line::from(vec![
if !placeholder.is_empty() {
Span::styled(placeholder.to_string(), AppStyles::hint())
} else {
Span::raw("")
},
Span::styled("█", AppStyles::cursor()),
])])
}
pub fn create_masked_input_content(app: &App) -> Text<'static> {
let (before_cursor, after_cursor) = if app.cursor_position < app.input.len() {
(app.cursor_position, app.input.len() - app.cursor_position)
} else {
(app.input.len(), 0)
};
let mut spans = vec![];
if before_cursor > 0 {
spans.push(Span::raw("*".repeat(before_cursor)));
}
spans.push(Span::styled("█", AppStyles::cursor()));
if after_cursor > 0 {
spans.push(Span::raw("*".repeat(after_cursor)));
}
Text::from(vec![Line::from(spans)])
}
pub fn create_single_line_input_content(app: &App) -> Text<'static> {
let text_style = Style::default()
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD);
let (before_cursor, after_cursor) = app.input.split_at(app.cursor_position);
let mut spans = vec![];
if !before_cursor.is_empty() {
spans.push(Span::styled(before_cursor.to_string(), text_style));
}
if app.cursor_position < app.input.len() {
if let Some(cursor_char) = after_cursor.chars().next() {
let first_char = cursor_char.to_string();
let rest_of_text = if after_cursor.len() > cursor_char.len_utf8() {
after_cursor[cursor_char.len_utf8()..].to_string()
} else {
"".to_string()
};
spans.push(Span::styled(first_char, AppStyles::cursor()));
if !rest_of_text.is_empty() {
spans.push(Span::styled(rest_of_text, text_style));
}
}
} else {
spans.push(Span::styled("█", AppStyles::cursor()));
}
Text::from(vec![Line::from(spans)])
}
pub fn create_multiline_input_content(app: &App) -> Text<'static> {
let text_style = Style::default()
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD);
let input_str = app.input.as_str();
let lines: Vec<&str> = input_str.split('\n').collect();
let trailing_newline = input_str.ends_with('\n');
let mut styled_lines = Vec::new();
let mut char_pos = 0;
let cursor_pos = app.cursor_position.min(input_str.len());
for (idx, line) in lines.iter().enumerate() {
let line_start_pos = char_pos;
let line_end_pos = line_start_pos + line.len();
let cursor_on_this_line = cursor_pos >= line_start_pos
&& (cursor_pos <= line_end_pos
|| (idx == lines.len() - 1 && trailing_newline && cursor_pos == line_end_pos + 1));
let line_padding = if idx == 0 { "" } else { " " };
if cursor_on_this_line {
add_line_with_cursor(
&mut styled_lines,
line,
line_padding,
cursor_pos - line_start_pos,
text_style,
);
} else {
let padding_span = if !line_padding.is_empty() {
Span::raw(line_padding)
} else {
Span::raw("")
};
let mut spans = Vec::new();
if !line_padding.is_empty() {
spans.push(padding_span);
}
spans.push(Span::styled(line.to_string(), text_style));
styled_lines.push(Line::from(spans));
}
char_pos = line_end_pos + 1;
}
if trailing_newline && cursor_pos == input_str.len() {
styled_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("█", AppStyles::cursor()),
]));
} else if trailing_newline {
styled_lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("", text_style),
]));
}
Text::from(styled_lines)
}
fn add_line_with_cursor(
styled_lines: &mut Vec<Line<'static>>,
line: &str,
line_padding: &str,
cursor_offset: usize,
text_style: Style,
) {
if cursor_offset <= line.len() {
let (before_cursor, after_cursor) = line.split_at(cursor_offset);
let mut spans = Vec::new();
if !line_padding.is_empty() {
spans.push(Span::raw(line_padding.to_string()));
}
if !before_cursor.is_empty() {
spans.push(Span::styled(before_cursor.to_string(), text_style));
}
if !after_cursor.is_empty() {
if let Some(cursor_char) = after_cursor.chars().next() {
let first_char = cursor_char.to_string();
let rest_of_text = if after_cursor.len() > cursor_char.len_utf8() {
after_cursor[cursor_char.len_utf8()..].to_string()
} else {
"".to_string()
};
spans.push(Span::styled(first_char, AppStyles::cursor()));
if !rest_of_text.is_empty() {
spans.push(Span::styled(rest_of_text, text_style));
}
} else {
spans.push(Span::styled("█", AppStyles::cursor()));
}
} else {
spans.push(Span::styled("█", AppStyles::cursor()));
}
styled_lines.push(Line::from(spans));
} else {
let mut spans = Vec::new();
if !line_padding.is_empty() {
spans.push(Span::raw(line_padding.to_string()));
}
spans.push(Span::styled(line.to_string(), text_style));
spans.push(Span::styled("█", AppStyles::cursor()));
styled_lines.push(Line::from(spans));
}
}
pub fn get_input_placeholder(app: &App, is_api_key: bool) -> &'static str {
if is_api_key {
match app.current_model().name.as_str() {
"GPT-4o" => "Enter your OpenAI API key and press Enter...",
_ => "Enter your Anthropic API key and press Enter...",
}
} else {
"" }
}
pub fn add_task_lines(
lines: &mut Vec<Line<'static>>,
task: &crate::app::state::Task,
animation_state: (bool, bool),
width: u16,
) {
let (_animation_active, highlight_on) = animation_state;
let (indicator, style) = match &task.status {
TaskStatus::InProgress => {
if highlight_on {
(
"⏺",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::SLOW_BLINK),
)
} else {
("⏺", Style::default().fg(Color::White))
}
}
TaskStatus::Completed {
duration: _,
tool_uses: _,
input_tokens: _,
output_tokens: _,
} => (
"⏺",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
TaskStatus::Failed(_) => (
"⏺",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
};
lines.push(Line::from(vec![
Span::styled(indicator, style),
Span::raw(" "),
Span::styled(
truncate_with_ellipsis(&task.description, width.saturating_sub(10) as usize),
Style::default().fg(Color::LightCyan),
),
]));
match &task.status {
TaskStatus::InProgress => {
lines.push(Line::from(vec![
Span::raw(" ⎿ "),
Span::styled(
format!(
"In progress ({} tool use{})",
task.tool_count,
if task.tool_count == 1 { "" } else { "s" }
),
Style::default().fg(Color::DarkGray),
),
]));
}
TaskStatus::Completed {
duration,
tool_uses,
input_tokens,
output_tokens,
} => {
let duration_secs = duration.as_secs_f32();
let total_tokens = input_tokens + output_tokens;
lines.push(Line::from(vec![
Span::raw(" ⎿ "),
Span::styled(
format!(
"Done ({} tool use{} · {:.1}k tokens [{:.1}k in/{:.1}k out] · {:.1}s)",
tool_uses,
if *tool_uses == 1 { "" } else { "s" },
total_tokens as f32 / 1000.0,
*input_tokens as f32 / 1000.0,
*output_tokens as f32 / 1000.0,
duration_secs
),
Style::default().fg(Color::DarkGray),
),
]));
}
TaskStatus::Failed(error) => {
lines.push(Line::from(vec![
Span::raw(" ⎿ "),
Span::styled(
format!(
"Failed: {}",
truncate_with_ellipsis(error, width.saturating_sub(15) as usize)
),
Style::default().fg(Color::Red),
),
]));
}
}
lines.push(Line::from(""));
}
pub fn create_detailed_shortcuts() -> Paragraph<'static> {
let shortcuts = [
("/ ", "Show commands menu"),
("Ctrl+j", "Add newline in input"),
];
let max_shortcut_length = shortcuts.iter().map(|(s, _)| s.len()).max().unwrap_or(0);
let mut lines = vec![Line::from(vec![Span::styled(
"Keyboard Shortcuts",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)])];
for (shortcut, description) in shortcuts.iter() {
let padding = " ".repeat(max_shortcut_length.saturating_sub(shortcut.len()) + 2);
lines.push(Line::from(vec![
Span::styled(
(*shortcut).to_string(),
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
),
Span::styled(padding, Style::default()),
Span::styled(
(*description).to_string(),
Style::default().fg(Color::DarkGray),
),
]));
}
Paragraph::new(Text::from(lines))
}
pub fn truncate_with_ellipsis(s: &str, max_length: usize) -> String {
if s.len() <= max_length {
s.to_string()
} else {
format!("{}{}", &s[0..max_length.saturating_sub(3)], "...")
}
}