use super::super::app::{App, DisplayMessage};
use super::super::markdown::parse_markdown;
use super::tools::{render_approve_menu, render_inline_approval, render_tool_group};
use super::utils::wrap_line_with_padding;
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Padding, Paragraph},
};
use unicode_width::UnicodeWidthStr;
use uuid::Uuid;
pub(crate) fn reasoning_to_lines(text: &str, max_width: usize) -> Vec<Line<'static>> {
let mut result = Vec::new();
for l in text.split('\n') {
let line = Line::from(Span::raw(l.to_string()));
for wrapped in wrap_line_with_padding(line, max_width, "") {
result.push(wrapped);
}
}
result
}
pub(crate) fn anchored_scroll_offset(
line_top: usize,
anchor_row: usize,
max_scroll: usize,
) -> (usize, bool) {
let desired_top = line_top.saturating_sub(anchor_row);
(
max_scroll.saturating_sub(desired_top),
desired_top >= max_scroll,
)
}
pub(crate) fn should_compensate_for_growth(
auto_scroll: bool,
scroll_offset: usize,
prev_lines: usize,
total_lines: usize,
expand_pending: bool,
) -> bool {
!auto_scroll
&& !expand_pending
&& scroll_offset > 0
&& prev_lines > 0
&& total_lines > prev_lines
}
pub(crate) fn format_turn_spinner_meta(elapsed_secs: u64, turn_tokens: u32) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if elapsed_secs > 0 {
parts.push(format!("{elapsed_secs}s"));
}
if turn_tokens > 0 {
parts.push(format!("{turn_tokens} tok this turn"));
}
(!parts.is_empty()).then(|| format!(" ({})", parts.join(" · ")))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ToolStack {
pub count: usize,
pub total_calls: usize,
pub end: usize,
}
pub(crate) fn scan_tool_stack(messages: &[DisplayMessage], start: usize) -> ToolStack {
let mut count = 0;
let mut total_calls = 0;
let mut end = start;
if let Some(g) = messages.get(start).and_then(|m| m.tool_group.as_ref()) {
count = 1;
total_calls = g.calls.len();
end = start + 1;
}
while end < messages.len() {
let m = &messages[end];
if let Some(ref g) = m.tool_group {
count += 1;
total_calls += g.calls.len();
end += 1;
} else if m.role == "assistant" && m.content.trim().is_empty() && m.details.is_some() {
end += 1;
} else {
break;
}
}
ToolStack {
count,
total_calls,
end,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct TurnRange {
pub start: usize,
pub end: usize,
}
pub(crate) fn turn_ranges(messages: &[DisplayMessage]) -> Vec<TurnRange> {
if messages.is_empty() {
return Vec::new();
}
let mut out: Vec<TurnRange> = Vec::new();
let mut start = 0usize;
for (i, m) in messages.iter().enumerate() {
if m.role == "user" && i > start {
out.push(TurnRange { start, end: i });
start = i;
}
}
out.push(TurnRange {
start,
end: messages.len(),
});
out
}
#[allow(dead_code)]
pub(crate) fn turn_of(messages: &[DisplayMessage], msg_idx: usize) -> Option<TurnRange> {
turn_ranges(messages)
.into_iter()
.find(|t| msg_idx >= t.start && msg_idx < t.end)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) struct TurnSummary {
pub tool_calls: usize,
pub failed: usize,
pub duration_secs: i64,
pub has_error: bool,
}
pub(crate) fn turn_summary(messages: &[DisplayMessage], turn: TurnRange) -> TurnSummary {
let slice = &messages[turn.start.min(messages.len())..turn.end.min(messages.len())];
let mut s = TurnSummary::default();
for m in slice {
if let Some(ref g) = m.tool_group {
s.tool_calls += g.calls.len();
s.failed += g.calls.iter().filter(|c| !c.success).count();
}
if m.role == "error" {
s.has_error = true;
}
}
if let (Some(first), Some(last)) = (slice.first(), slice.last()) {
s.duration_secs = (last.timestamp - first.timestamp).num_seconds().max(0);
}
s
}
pub(crate) fn format_turn_header(summary: TurnSummary) -> Option<String> {
if summary.tool_calls == 0 {
return None;
}
let status = if summary.has_error || summary.failed > 0 {
"✗"
} else {
"✓"
};
let calls = if summary.tool_calls == 1 {
"1 tool call".to_string()
} else {
format!("{} tool calls", summary.tool_calls)
};
let mut parts = vec![calls];
if summary.failed > 0 {
parts.push(format!("{} failed", summary.failed));
}
if summary.duration_secs > 0 {
parts.push(humanize_secs(summary.duration_secs));
}
Some(format!("{status} {}", parts.join(" · ")))
}
fn humanize_secs(secs: i64) -> String {
match secs {
s if s < 60 => format!("{s}s"),
s if s < 3600 => {
let (m, r) = (s / 60, s % 60);
if r == 0 {
format!("{m}m")
} else {
format!("{m}m {r}s")
}
}
s => {
let (h, m) = (s / 3600, (s % 3600) / 60);
if m == 0 {
format!("{h}h")
} else {
format!("{h}h {m}m")
}
}
}
}
pub(crate) fn final_answer_idx(messages: &[DisplayMessage], turn: TurnRange) -> Option<usize> {
(turn.start..turn.end.min(messages.len())).rev().find(|&i| {
let m = &messages[i];
m.role == "assistant" && !m.content.trim().is_empty()
})
}
pub(crate) fn settled_final_answer_idx(
messages: &[DisplayMessage],
turn: TurnRange,
is_newest: bool,
is_processing: bool,
) -> Option<usize> {
if is_newest && is_processing {
return None;
}
final_answer_idx(messages, turn)
}
pub(crate) fn folded_turn_preview(
messages: &[DisplayMessage],
turn: TurnRange,
) -> Option<(String, bool)> {
(turn.start..turn.end.min(messages.len()))
.rev()
.find_map(|i| {
let last = messages[i].tool_group.as_ref()?.calls.last()?;
Some((last.description.clone(), last.success))
})
}
pub(crate) fn visible_when_folded(
messages: &[DisplayMessage],
idx: usize,
final_idx: Option<usize>,
) -> bool {
let Some(m) = messages.get(idx) else {
return false;
};
if m.approval.is_some() || m.approve_menu.is_some() {
return true;
}
match m.role.as_str() {
"user" | "error" | "history_marker" => true,
_ => Some(idx) == final_idx,
}
}
pub(crate) const THINKING_EXCERPT_LINES: usize = 3;
pub(crate) fn turn_has_hideable(
messages: &[DisplayMessage],
turn: TurnRange,
final_idx: Option<usize>,
) -> bool {
(turn.start..turn.end.min(messages.len()))
.any(|idx| !visible_when_folded(messages, idx, final_idx))
}
pub(crate) fn turn_is_folded(
overrides: &std::collections::HashMap<Uuid, bool>,
anchor_id: Uuid,
) -> bool {
match overrides.get(&anchor_id) {
Some(expanded) => !expanded,
None => true,
}
}
struct TurnHeader {
base: String,
hint: &'static str,
preview: Option<(String, bool)>,
}
pub(super) fn render_chat(f: &mut Frame, app: &mut App, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
let mut line_to_msg: Vec<Option<usize>> = Vec::new();
let content_width = area.width.saturating_sub(4) as usize;
let mut skip_count: usize = 0;
let all_turns = turn_ranges(&app.messages);
let mut turn_headers: std::collections::HashMap<usize, TurnHeader> =
std::collections::HashMap::new();
let newest_start = all_turns.last().map(|t| t.start);
let mut header_anchor: std::collections::HashMap<usize, Uuid> =
std::collections::HashMap::new();
let mut row_visible: std::collections::HashMap<usize, bool> = std::collections::HashMap::new();
for t in &all_turns {
let Some(anchor_id) = app.messages.get(t.start).map(|m| m.id) else {
continue;
};
let folded = turn_is_folded(&app.turn_expanded, anchor_id);
let summary = turn_summary(&app.messages, *t);
let final_idx = settled_final_answer_idx(
&app.messages,
*t,
newest_start == Some(t.start),
app.is_processing,
);
let has_hideable = turn_has_hideable(&app.messages, *t, final_idx);
if folded {
for idx in t.start..t.end.min(app.messages.len()) {
if !visible_when_folded(&app.messages, idx, final_idx) {
row_visible.insert(idx, false);
}
}
}
let hid_something = has_hideable;
if let Some(base) = format_turn_header(summary)
.or_else(|| hid_something.then(|| format!("{} steps", t.end.saturating_sub(t.start))))
{
let newest = newest_start == Some(t.start);
let hint = match (folded, newest) {
(true, true) => " (click / ctrl+o to expand)",
(true, false) => " (click to expand)",
(false, true) => " (click / ctrl+o to fold)",
(false, false) => " (click to fold)",
};
let at = if app.messages.get(t.start).is_some_and(|m| m.role == "user") {
t.start + 1
} else {
t.start
};
if at < t.end {
turn_headers.insert(
at,
TurnHeader {
base,
hint,
preview: folded
.then(|| folded_turn_preview(&app.messages, *t))
.flatten(),
},
);
header_anchor.insert(at, anchor_id);
}
}
}
let mut line_to_turn: Vec<Option<Uuid>> = Vec::new();
for msg_idx in 0..app.messages.len() {
if skip_count > 0 {
skip_count -= 1;
continue;
}
if let Some(header) = turn_headers.get(&msg_idx) {
lines.push(Line::from(vec![
Span::styled(
format!(" {}", header.base),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
header.hint.to_string(),
Style::default().fg(Color::Rgb(100, 100, 100)),
),
]));
line_to_msg.resize(lines.len(), None);
line_to_turn.resize(lines.len(), None);
if let (Some(anchor), Some(slot)) =
(header_anchor.get(&msg_idx), line_to_turn.last_mut())
{
*slot = Some(*anchor);
}
if let Some((description, success)) = &header.preview {
let style = if *success {
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC)
} else {
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::ITALIC)
};
lines.push(Line::from(vec![
Span::styled(" └─ ", Style::default().fg(Color::DarkGray)),
Span::styled(description.clone(), style),
]));
line_to_msg.resize(lines.len(), None);
line_to_turn.resize(lines.len(), None);
if let (Some(anchor), Some(slot)) =
(header_anchor.get(&msg_idx), line_to_turn.last_mut())
{
*slot = Some(*anchor);
}
}
lines.push(Line::from(""));
line_to_msg.resize(lines.len(), None);
line_to_turn.resize(lines.len(), None);
}
if row_visible.get(&msg_idx) == Some(&false) {
continue;
}
let lines_before = lines.len();
if let Some(ref approval) = app.messages[msg_idx].approval {
render_inline_approval(&mut lines, approval, content_width);
lines.push(Line::from(""));
line_to_msg.resize(lines.len(), None);
continue;
}
if let Some(ref menu) = app.messages[msg_idx].approve_menu {
render_approve_menu(&mut lines, menu, content_width);
lines.push(Line::from(""));
line_to_msg.resize(lines.len(), None);
continue;
}
if app.messages[msg_idx].role == "history_marker" {
lines.push(Line::from(Span::styled(
app.messages[msg_idx].content.clone(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
)));
lines.push(Line::from(""));
line_to_msg.resize(lines.len(), None);
continue;
}
if let Some(ref group) = app.messages[msg_idx].tool_group {
let stack = scan_tool_stack(&app.messages, msg_idx);
let stack_count = stack.count;
let stack_total_calls = stack.total_calls;
let lookahead = stack.end;
if stack_count >= 3 {
let dot = "●";
let stack_expanded = group.expanded; let header = if stack_total_calls == stack_count {
format!("{} tool calls", stack_total_calls)
} else {
format!("{} tool calls ({} groups)", stack_total_calls, stack_count)
};
let mut header_spans = vec![Span::styled(
format!(" {} {}", dot, header),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)];
header_spans.push(Span::styled(
if stack_expanded {
" (ctrl+o to collapse)"
} else {
" (ctrl+o to expand)"
},
Style::default().fg(Color::Rgb(100, 100, 100)),
));
lines.push(Line::from(header_spans));
if stack_expanded {
let mut group_idx = 0;
for i in msg_idx..lookahead {
if let Some(ref g) = app.messages[i].tool_group {
let connector = if group_idx == stack_count - 1 {
"└─"
} else {
"├─"
};
if let Some(last) = g.calls.last() {
let style = if last.success {
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC)
} else {
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::ITALIC)
};
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", connector),
Style::default().fg(Color::DarkGray),
),
Span::styled(last.description.clone(), style),
]));
}
group_idx += 1;
}
}
} else {
if let Some(last) = group.calls.last() {
let style = if last.success {
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC)
} else {
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::ITALIC)
};
lines.push(Line::from(vec![
Span::styled(
" └─ ".to_string(),
Style::default().fg(Color::DarkGray),
),
Span::styled(last.description.clone(), style),
]));
}
}
lines.push(Line::from(""));
line_to_msg.resize(lines.len(), Some(msg_idx));
skip_count = lookahead - msg_idx - 1;
continue;
}
render_tool_group(&mut lines, group, false, app.animation_frame, content_width);
lines.push(Line::from(""));
line_to_msg.resize(lines.len(), Some(msg_idx));
continue;
}
if app.messages[msg_idx].role == "system" {
let system_style = Style::default()
.fg(Color::Rgb(200, 170, 60))
.add_modifier(Modifier::ITALIC);
for (i, text_line) in app.messages[msg_idx].content.lines().enumerate() {
let mut spans = vec![Span::styled(" ", Style::default())];
if i == 0 {
spans.push(Span::styled("⚡ ", system_style));
} else {
spans.push(Span::styled(" ", Style::default()));
}
spans.push(Span::styled(text_line.to_string(), system_style));
if i == 0 && app.messages[msg_idx].details.is_some() {
let hint = if app.messages[msg_idx].expanded {
" (ctrl+o to collapse)"
} else {
" (ctrl+o to expand)"
};
spans.push(Span::styled(
hint,
Style::default().fg(Color::Rgb(120, 120, 120)),
));
}
let line = Line::from(spans);
for wrapped in wrap_line_with_padding(line, content_width, " ") {
lines.push(wrapped);
}
}
if app.messages[msg_idx].expanded
&& let Some(ref details) = app.messages[msg_idx].details
{
for detail_line in details.lines() {
let (style, line_text): (Style, &str) =
if let Some(stripped) = detail_line.strip_prefix("+ ") {
(Style::default().fg(Color::Green), stripped)
} else if let Some(stripped) = detail_line.strip_prefix("- ") {
(Style::default().fg(Color::Red), stripped)
} else {
(Style::default().fg(Color::DarkGray), detail_line)
};
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(line_text.to_string(), style),
]));
}
}
lines.push(Line::from(""));
for _ in lines_before..lines.len() {
line_to_msg.push(Some(msg_idx));
}
continue;
}
if app.messages[msg_idx].role == "error" {
let err_style = Style::default()
.fg(Color::Rgb(220, 70, 70))
.add_modifier(Modifier::BOLD);
let body_style = Style::default().fg(Color::Rgb(220, 130, 130));
for (i, text_line) in app.messages[msg_idx].content.lines().enumerate() {
let mut spans = vec![Span::styled(" ", Style::default())];
if i == 0 {
spans.push(Span::styled("❌ Error: ", err_style));
} else {
spans.push(Span::styled(" ", Style::default()));
}
spans.push(Span::styled(text_line.to_string(), body_style));
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
for _ in lines_before..lines.len() {
line_to_msg.push(Some(msg_idx));
}
continue;
}
let is_user = app.messages[msg_idx].role == "user";
let is_selected = app.selected_message_idx == Some(msg_idx);
let msg_bg: Option<Color> = if is_selected {
Some(Color::Rgb(40, 45, 55))
} else if is_user {
Some(Color::Rgb(40, 44, 56))
} else {
None
};
let msg_id = app.messages[msg_idx].id;
let cache_key = (msg_id, content_width as u16);
if !app.render_cache.contains_key(&cache_key) {
let parsed = parse_markdown(
&app.messages[msg_idx].content,
content_width.saturating_sub(2),
);
app.render_cache.insert(cache_key, parsed);
}
let content_lines = app.render_cache[&cache_key].clone();
for (i, line) in content_lines.into_iter().enumerate() {
let mut padded_spans = if i == 0 {
if is_user {
vec![Span::styled(
"\u{276F} ",
Style::default().fg(Color::Rgb(100, 100, 100)),
)]
} else {
vec![Span::styled(
"\u{25CF} ",
Style::default()
.fg(Color::Rgb(120, 120, 120))
.add_modifier(Modifier::BOLD),
)]
}
} else {
vec![Span::raw(" ")]
};
padded_spans.extend(line.spans);
let padded_line = Line::from(padded_spans);
for wrapped in wrap_line_with_padding(padded_line, content_width, " ") {
if let Some(bg) = msg_bg {
let mut spans: Vec<Span> = wrapped
.spans
.into_iter()
.map(|s| {
let style = if is_user {
if s.style.fg.is_some() {
s.style.bg(bg)
} else {
s.style.bg(bg).fg(Color::White)
}
} else {
s.style.bg(bg)
};
Span::styled(s.content, style)
})
.collect();
let line_width: usize = spans.iter().map(|s| s.content.width()).sum();
let remaining = content_width.saturating_sub(line_width);
if remaining > 0 {
spans.push(Span::styled(" ".repeat(remaining), Style::default().bg(bg)));
}
lines.push(Line::from(spans));
} else {
lines.push(wrapped);
}
}
}
let has_reasoning = app.messages[msg_idx]
.details
.as_ref()
.is_some_and(|d| !d.trim().is_empty());
if !is_user && has_reasoning {
lines.push(Line::from(""));
let expanded = app.messages[msg_idx].expanded;
let full = app.messages[msg_idx].expanded_full;
let hint_text = match (expanded, full) {
(false, _) => " ▸ Thinking (click / ctrl+o to expand)",
(true, false) => " ▾ Thinking (click / ctrl+o for full)",
(true, true) => " ▾ Thinking (click / ctrl+o to collapse)",
};
let hint_span = Span::styled(
hint_text.to_string(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
);
lines.push(Line::from(vec![hint_span]));
if expanded && let Some(ref details) = app.messages[msg_idx].details {
lines.push(Line::from(""));
let inner_width = content_width.saturating_sub(2);
let reasoning_lines = reasoning_to_lines(details, inner_width);
let reasoning_style = Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC);
const REASONING_CAP: usize = 10;
let total = reasoning_lines.len();
let show = if full {
total
} else {
total.min(REASONING_CAP)
};
for line in reasoning_lines.into_iter().take(show) {
let mut padded_spans = vec![Span::styled(" ", Style::default())];
for span in line.spans {
padded_spans.push(Span::styled(span.content.to_string(), reasoning_style));
}
lines.push(Line::from(padded_spans));
}
if !full && total > show {
lines.push(Line::from(vec![Span::styled(
format!(" … {} more lines (click / ctrl+o for full)", total - show),
Style::default()
.fg(Color::Rgb(100, 100, 100))
.add_modifier(Modifier::ITALIC),
)]));
}
}
}
let msg_lines_end = lines.len();
for _ in lines_before..msg_lines_end {
line_to_msg.push(Some(msg_idx));
}
lines.push(Line::from(""));
line_to_msg.push(None);
}
let has_pending_approval = app.has_pending_approval();
if !has_pending_approval
&& app.auto_scroll
&& let Some(ref response) = app.streaming_response
{
if let Some(ref reasoning) = app.streaming_reasoning {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
"Thinking...",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC | Modifier::BOLD),
),
]));
const MAX_THINKING_LINES: usize = 12;
let inner_width = content_width.saturating_sub(2);
let reasoning_lines = reasoning_to_lines(reasoning, inner_width);
let reasoning_style = Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC);
let start_idx = reasoning_lines.len().saturating_sub(MAX_THINKING_LINES);
if start_idx > 0 {
lines.push(Line::from(Span::styled(
format!(" ⋯ {} more lines", start_idx),
Style::default().fg(Color::DarkGray),
)));
}
for line in reasoning_lines.into_iter().skip(start_idx) {
let mut padded_spans = vec![Span::styled(" ", Style::default())];
for span in line.spans {
padded_spans.push(Span::styled(span.content.to_string(), reasoning_style));
}
lines.push(Line::from(padded_spans));
}
lines.push(Line::from("")); }
let current_len = response.len();
let streaming_lines = if let Some((cached_len, ref cached)) = app.streaming_render_cache {
if cached_len == current_len {
cached.clone()
} else {
let clean = crate::utils::sanitize::strip_llm_artifacts(response);
let parsed = parse_markdown(&clean, content_width.saturating_sub(2));
app.streaming_render_cache = Some((current_len, parsed.clone()));
parsed
}
} else {
let clean = crate::utils::sanitize::strip_llm_artifacts(response);
let parsed = parse_markdown(&clean, content_width);
app.streaming_render_cache = Some((current_len, parsed.clone()));
parsed
};
for line in streaming_lines {
let mut padded_spans = vec![Span::raw(" ")];
padded_spans.extend(line.spans);
let padded_line = Line::from(padded_spans);
for wrapped in wrap_line_with_padding(padded_line, content_width, " ") {
lines.push(wrapped);
}
}
lines.push(Line::from(""));
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let frame = spinner_frames[app.animation_frame % spinner_frames.len()];
let elapsed = app
.processing_started_at
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
let mut spans = vec![
Span::styled(
format!("{} ", frame),
Style::default()
.fg(Color::Rgb(120, 120, 120))
.add_modifier(Modifier::BOLD),
),
Span::styled(
"🦀 OpenCrabs ",
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"is responding...",
Style::default().fg(Color::Rgb(215, 100, 20)),
),
];
if let Some(meta) = format_turn_spinner_meta(elapsed, app.streaming_output_tokens) {
spans.push(Span::styled(meta, Style::default().fg(Color::DarkGray)));
}
lines.push(Line::from(spans));
}
if !has_pending_approval
&& app.auto_scroll
&& app.streaming_response.is_none()
&& let Some(ref reasoning) = app.streaming_reasoning
{
let excerpt = crate::utils::string::thinking_excerpt_capped(
reasoning,
crate::utils::string::THINKING_EXCERPT_CHARS,
)
.unwrap_or_else(|| "Thinking…".to_string());
let style = Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC);
let wrapped = wrap_line_with_padding(
Line::from(Span::styled(excerpt, style)),
content_width.saturating_sub(5),
"",
);
let overflows = wrapped.len() > THINKING_EXCERPT_LINES;
for (i, wrapped_line) in wrapped.into_iter().take(THINKING_EXCERPT_LINES).enumerate() {
let prefix = if i == 0 { " 🧠 " } else { " " };
let mut spans = vec![Span::styled(prefix, Style::default().fg(Color::DarkGray))];
spans.extend(wrapped_line.spans);
if overflows && i == THINKING_EXCERPT_LINES - 1 {
spans.push(Span::styled("…", style));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let frame = spinner_frames[app.animation_frame % spinner_frames.len()];
let elapsed = app
.processing_started_at
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
let mut header_spans = vec![
Span::styled(
format!("{} ", frame),
Style::default()
.fg(Color::Rgb(120, 120, 120))
.add_modifier(Modifier::BOLD),
),
Span::styled(
"🦀 OpenCrabs ",
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"is thinking...",
Style::default().fg(Color::Rgb(215, 100, 20)),
),
];
if let Some(meta) = format_turn_spinner_meta(elapsed, app.streaming_output_tokens) {
header_spans.push(Span::styled(meta, Style::default().fg(Color::DarkGray)));
}
lines.push(Line::from(header_spans));
}
if !has_pending_approval
&& app.auto_scroll
&& app.is_processing
&& app.streaming_response.is_none()
&& app.streaming_reasoning.is_none()
{
let spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let frame = spinner_frames[app.animation_frame % spinner_frames.len()];
let elapsed = app
.processing_started_at
.map(|t| t.elapsed().as_secs())
.unwrap_or(0);
let mut spans = vec![
Span::styled(
format!(" {} ", frame),
Style::default()
.fg(Color::Rgb(120, 120, 120))
.add_modifier(Modifier::BOLD),
),
Span::styled(
"OpenCrabs is thinking...",
Style::default().fg(Color::Rgb(215, 100, 20)),
),
];
if let Some(meta) = format_turn_spinner_meta(elapsed, app.streaming_output_tokens) {
spans.push(Span::styled(
meta,
Style::default().fg(Color::Rgb(100, 100, 100)),
));
}
lines.push(Line::from(spans));
}
if app.auto_scroll
&& let Some(ref group) = app.active_tool_group
{
let is_active = group.calls.iter().any(|c| !c.completed);
render_tool_group(
&mut lines,
group,
is_active,
app.animation_frame,
content_width,
);
}
if let Some(ref error) = app.error_message {
lines.push(Line::from(""));
let prefix = " Error: ";
let prefix_len = prefix.len();
let wrap_w = content_width.saturating_sub(prefix_len + 2).max(20);
let mut first = true;
for raw_line in error.lines() {
let mut current = String::new();
for word in raw_line.split_whitespace() {
if current.len() + word.len() + 1 > wrap_w {
if first {
lines.push(Line::from(vec![
Span::styled(
prefix,
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(current.clone(), Style::default().fg(Color::Red)),
]));
first = false;
} else {
lines.push(Line::from(Span::styled(
format!("{}{}", " ".repeat(prefix_len), current),
Style::default().fg(Color::Red),
)));
}
current.clear();
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
if !current.is_empty() || first {
if first {
lines.push(Line::from(vec![
Span::styled(
prefix,
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(current.clone(), Style::default().fg(Color::Red)),
]));
first = false;
} else {
lines.push(Line::from(Span::styled(
format!("{}{}", " ".repeat(prefix_len), current),
Style::default().fg(Color::Red),
)));
}
}
}
lines.push(Line::from(""));
}
if let Some(ref note) = app.notification {
if app
.notification_shown_at
.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(2))
{
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
note.clone(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
]));
lines.push(Line::from(""));
} else {
app.notification = None;
app.notification_shown_at = None;
}
}
if let Some(ref ssh_req) = app.ssh_pending {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
" \u{1F511} ",
Style::default().fg(Color::Rgb(75, 160, 215)),
),
Span::styled(
"SSH password required",
Style::default()
.fg(Color::Rgb(75, 160, 215))
.add_modifier(Modifier::BOLD),
),
]));
let target_display = if ssh_req.target.len() > 60 {
format!("{}...", ssh_req.target.chars().take(57).collect::<String>())
} else {
ssh_req.target.clone()
};
lines.push(Line::from(vec![
Span::styled(" Target: ", Style::default().fg(Color::DarkGray)),
Span::styled(target_display, Style::default().fg(Color::Reset)),
]));
lines.push(Line::from(vec![
Span::styled(" Password: ", Style::default().fg(Color::DarkGray)),
Span::styled(
"\u{2022}".repeat(app.ssh_input.len()),
Style::default().fg(Color::Reset),
),
Span::styled("\u{2588}", Style::default().fg(Color::Rgb(120, 120, 120))),
]));
lines.push(Line::from(vec![
Span::styled(
" [Enter] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled("Submit ", Style::default().fg(Color::DarkGray)),
Span::styled(
"[Esc] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled("Cancel", Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::from(""));
}
if let Some(ref sudo_req) = app.sudo_pending {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
" \u{1F512} ",
Style::default().fg(Color::Rgb(215, 100, 20)),
),
Span::styled(
"sudo password required",
Style::default()
.fg(Color::Rgb(215, 100, 20))
.add_modifier(Modifier::BOLD),
),
]));
let cmd_display = if sudo_req.command.len() > 60 {
format!(
"{}...",
sudo_req.command.chars().take(57).collect::<String>()
)
} else {
sudo_req.command.clone()
};
lines.push(Line::from(vec![
Span::styled(" Command: ", Style::default().fg(Color::DarkGray)),
Span::styled(cmd_display, Style::default().fg(Color::Reset)),
]));
lines.push(Line::from(vec![
Span::styled(" Password: ", Style::default().fg(Color::DarkGray)),
Span::styled(
"\u{2022}".repeat(app.sudo_input.len()),
Style::default().fg(Color::Reset),
),
Span::styled("\u{2588}", Style::default().fg(Color::Rgb(120, 120, 120))),
]));
lines.push(Line::from(vec![
Span::styled(
" [Enter] ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled("Submit ", Style::default().fg(Color::DarkGray)),
Span::styled(
"[Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled("Cancel", Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::from(""));
}
line_to_msg.resize(lines.len(), None);
app.chat_line_to_msg = line_to_msg;
line_to_turn.resize(app.chat_line_to_msg.len(), None);
app.chat_line_to_turn = line_to_turn;
let total_lines = lines.len();
if should_compensate_for_growth(
app.auto_scroll,
app.scroll_offset,
app.prev_rendered_lines,
total_lines,
app.chat_expand_anchor.is_some() || app.chat_expand_anchor_turn.is_some(),
) {
let delta = total_lines - app.prev_rendered_lines;
app.scroll_offset += delta;
tracing::debug!(
"[SCROLL] compensated: scroll_offset += {} (delta), now {}",
delta,
app.scroll_offset
);
}
app.prev_rendered_lines = total_lines;
let visible_height = area.height.saturating_sub(1) as usize;
let max_scroll = total_lines.saturating_sub(visible_height);
if let Some((anchor_idx, anchor_row)) = app.chat_expand_anchor.take()
&& let Some(line_top) = app
.chat_line_to_msg
.iter()
.position(|m| *m == Some(anchor_idx))
{
let (offset, auto) = anchored_scroll_offset(line_top, anchor_row as usize, max_scroll);
app.scroll_offset = offset;
app.auto_scroll = auto;
}
if let Some((anchor, anchor_row)) = app.chat_expand_anchor_turn.take()
&& let Some(line_top) = app
.chat_line_to_turn
.iter()
.position(|t| *t == Some(anchor))
{
let (offset, auto) = anchored_scroll_offset(line_top, anchor_row as usize, max_scroll);
app.scroll_offset = offset;
app.auto_scroll = auto;
}
let before_cap = app.scroll_offset;
app.scroll_offset = app.scroll_offset.min(max_scroll);
if before_cap != app.scroll_offset {
tracing::debug!(
"[SCROLL] capped: {} -> {} (max_scroll={})",
before_cap,
app.scroll_offset,
max_scroll
);
}
let actual_scroll_offset = max_scroll.saturating_sub(app.scroll_offset);
{
{
type ScrollSnapshot = (usize, usize, usize, bool, usize);
static LAST_SCROLL_LOG: std::sync::Mutex<Option<ScrollSnapshot>> =
std::sync::Mutex::new(None);
let snapshot = (
app.scroll_offset,
total_lines,
actual_scroll_offset,
app.auto_scroll,
max_scroll,
);
let mut last = LAST_SCROLL_LOG.lock().unwrap_or_else(|e| e.into_inner());
if last.map(|l| l != snapshot).unwrap_or(true) {
{
tracing::trace!(
"[SCROLL] offset={} total={} actual={} auto={} max={} height={}",
app.scroll_offset,
total_lines,
actual_scroll_offset,
app.auto_scroll,
max_scroll,
visible_height
);
*last = Some(snapshot);
}
}
}
}
app.chat_render_scroll = actual_scroll_offset;
app.chat_area_y = area.y;
app.chat_area_x = area.x;
app.chat_area_width = area.width;
app.chat_area_height = area.height;
app.chat_rendered_lines = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect();
let chat = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::NONE)
.padding(Padding::new(1, 1, 1, 0)),
)
.scroll(((actual_scroll_offset.min(u16::MAX as usize)) as u16, 0));
f.render_widget(Clear, area);
f.render_widget(chat, area);
if let (Some(a), Some(b)) = (app.drag_anchor, app.drag_current) {
let (start, end) = if (a.1, a.0) <= (b.1, b.0) {
(a, b)
} else {
(b, a)
};
let buf = f.buffer_mut();
let x0 = area.x;
let y0 = area.y;
let x1 = area.x.saturating_add(area.width);
let y1 = area.y.saturating_add(area.height);
let highlight = Style::default().add_modifier(Modifier::REVERSED);
if start.1 == end.1 {
let y = start.1;
if y >= y0 && y < y1 {
let sx = start.0.max(x0);
let ex = end.0.min(x1.saturating_sub(1));
for x in sx..=ex {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(highlight);
}
}
}
} else {
for y in start.1..=end.1 {
if y < y0 || y >= y1 {
continue;
}
let sx = if y == start.1 { start.0 } else { x0 };
let ex = if y == end.1 {
end.0.min(x1.saturating_sub(1))
} else {
x1.saturating_sub(1)
};
for x in sx.max(x0)..=ex {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_style(highlight);
}
}
}
}
}
}