use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::palette;
use crate::tui::markdown_render;
pub(super) const REASONING_OPENER: &str = "\u{2026}"; pub(super) const REASONING_RAIL: &str = "\u{254E} "; pub(super) const REASONING_CURSOR: &str = "\u{258E}";
const THINKING_SUMMARY_LINE_LIMIT: usize = 4;
const THINKING_COMPLETED_PREVIEW_LINE_LIMIT: usize = 10;
const THINKING_STREAMING_PREVIEW_LINE_LIMIT: usize = 12;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ThinkingVisualState {
Live,
Done,
Idle,
}
#[allow(dead_code)] #[must_use]
pub fn extract_reasoning_summary(text: &str) -> Option<String> {
extract_explicit_reasoning_summary(text).or_else(|| {
let fallback = text.trim();
if fallback.is_empty() {
None
} else {
Some(fallback.to_string())
}
})
}
fn extract_explicit_reasoning_summary(text: &str) -> Option<String> {
let mut lines = text.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = line.trim();
if trimmed.to_lowercase().starts_with("summary") {
let mut summary = String::new();
if let Some((_, rest)) = trimmed.split_once(':')
&& !rest.trim().is_empty()
{
summary.push_str(rest.trim());
summary.push('\n');
}
while let Some(next) = lines.peek() {
let next_trimmed = next.trim();
if next_trimmed.is_empty() {
break;
}
if next_trimmed.starts_with('#') || next_trimmed.starts_with("**") {
break;
}
summary.push_str(next_trimmed);
summary.push('\n');
lines.next();
}
let summary = summary.trim().to_string();
return if summary.is_empty() {
None
} else {
Some(summary)
};
}
}
None
}
pub(super) fn render_thinking(
content: &str,
width: u16,
streaming: bool,
duration_secs: Option<f32>,
collapsed: bool,
low_motion: bool,
) -> Vec<Line<'static>> {
render_thinking_with_analysis(
content,
width,
streaming,
duration_secs,
collapsed,
low_motion,
true,
)
.0
}
pub(crate) fn render_thinking_with_analysis(
content: &str,
width: u16,
streaming: bool,
duration_secs: Option<f32>,
collapsed: bool,
low_motion: bool,
highlight: bool,
) -> (Vec<Line<'static>>, bool) {
let state = thinking_visual_state(streaming, duration_secs);
let style = thinking_style();
let depth = cached_color_depth();
let body_bg = palette::reasoning_surface_tint(depth);
let body_style = match (highlight, body_bg) {
(true, Some(bg)) => style.italic().bg(bg),
(_, None) | (false, Some(_)) => style.italic(),
};
let mut lines = Vec::new();
let mut header_spans = vec![
Span::styled(
format!("{REASONING_OPENER} "),
Style::default().fg(thinking_state_accent(state)),
),
Span::styled("reasoning", thinking_title_style()),
];
header_spans.push(Span::styled(" ", Style::default()));
header_spans.push(Span::styled(
thinking_status_label(state),
thinking_status_style(state),
));
if let Some(dur) = duration_secs {
header_spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
header_spans.push(Span::styled(
crate::elapsed::format_elapsed_ms((dur * 1000.0) as u64),
thinking_meta_style(),
));
}
lines.push(Line::from(header_spans));
let content_width = width.saturating_sub(3).max(1);
let (collapsed_body, expandable) =
collapsed_thinking_body(content, width, streaming, body_style);
let rendered = if collapsed {
collapsed_body
} else if content.trim().is_empty() {
Vec::new()
} else {
markdown_render::render_markdown(content, content_width, body_style)
};
let rail_style = Style::default().fg(thinking_state_accent(state));
let cursor_style = Style::default().fg(palette::ACCENT_REASONING_LIVE);
if rendered.is_empty() && streaming {
let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
spans.push(Span::styled("reasoning...", body_style.italic()));
if !low_motion {
spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style));
}
lines.push(Line::from(spans));
}
let last_idx = rendered.len().saturating_sub(1);
for (idx, line) in rendered.into_iter().enumerate() {
let mut spans = vec![Span::styled(REASONING_RAIL.to_string(), rail_style)];
spans.extend(line.spans);
if streaming && !low_motion && idx == last_idx {
spans.push(Span::styled(format!(" {REASONING_CURSOR}"), cursor_style));
}
lines.push(Line::from(spans));
}
if collapsed && expandable {
lines.push(Line::from(vec![
Span::styled(REASONING_RAIL.to_string(), rail_style),
Span::styled(
REASONING_OPENER,
Style::default().fg(palette::TEXT_MUTED).italic(),
),
]));
}
(lines, expandable)
}
fn collapsed_thinking_body(
content: &str,
width: u16,
streaming: bool,
style: Style,
) -> (Vec<Line<'static>>, bool) {
let (body_text, without_explicit_summary) = if streaming {
(content.to_string(), false)
} else {
match extract_explicit_reasoning_summary(content) {
Some(summary) => (summary, false),
None => (content.to_string(), true),
}
};
let mut lines = if body_text.trim().is_empty() {
Vec::new()
} else {
markdown_render::render_markdown(&body_text, width.saturating_sub(3).max(1), style)
};
let limit = if streaming {
THINKING_STREAMING_PREVIEW_LINE_LIMIT
} else if without_explicit_summary {
THINKING_COMPLETED_PREVIEW_LINE_LIMIT
} else {
THINKING_SUMMARY_LINE_LIMIT
};
let truncated = lines.len() > limit;
if truncated {
if streaming {
lines.drain(0..lines.len() - limit);
} else {
lines.truncate(limit);
}
}
let meaningful = truncated || (!streaming && body_text.trim() != content.trim());
(lines, meaningful)
}
pub(super) fn render_hidden_thinking_activity(
_width: u16,
duration_secs: Option<f32>,
low_motion: bool,
) -> Vec<Line<'static>> {
let state = ThinkingVisualState::Live;
let mut header_spans = vec![
Span::styled(
format!("{REASONING_OPENER} "),
Style::default().fg(thinking_state_accent(state)),
),
Span::styled("reasoning hidden", thinking_title_style()),
];
if let Some(dur) = duration_secs {
header_spans.push(Span::styled(" · ", Style::default().fg(palette::TEXT_DIM)));
header_spans.push(Span::styled(
crate::elapsed::format_elapsed_ms((dur * 1000.0) as u64),
thinking_meta_style(),
));
}
if !low_motion {
header_spans.push(Span::styled(
format!(" {REASONING_CURSOR}"),
Style::default().fg(palette::ACCENT_REASONING_LIVE),
));
}
vec![Line::from(header_spans)]
}
fn thinking_style() -> Style {
Style::default().fg(palette::TEXT_REASONING)
}
fn thinking_visual_state(streaming: bool, duration_secs: Option<f32>) -> ThinkingVisualState {
if streaming {
ThinkingVisualState::Live
} else if duration_secs.is_some() {
ThinkingVisualState::Done
} else {
ThinkingVisualState::Idle
}
}
fn thinking_status_label(state: ThinkingVisualState) -> &'static str {
match state {
ThinkingVisualState::Live => "live",
ThinkingVisualState::Done => "done",
ThinkingVisualState::Idle => "idle",
}
}
fn thinking_title_style() -> Style {
Style::default()
.fg(palette::TEXT_SOFT)
.add_modifier(Modifier::BOLD)
}
fn thinking_status_style(state: ThinkingVisualState) -> Style {
Style::default().fg(match state {
ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE,
ThinkingVisualState::Done => palette::TEXT_DIM,
ThinkingVisualState::Idle => palette::TEXT_DIM,
})
}
fn thinking_meta_style() -> Style {
Style::default().fg(palette::TEXT_DIM)
}
fn thinking_state_accent(state: ThinkingVisualState) -> Color {
match state {
ThinkingVisualState::Live => palette::ACCENT_REASONING_LIVE,
ThinkingVisualState::Done => palette::TEXT_DIM,
ThinkingVisualState::Idle => palette::TEXT_DIM,
}
}
static COLOR_DEPTH: std::sync::OnceLock<palette::ColorDepth> = std::sync::OnceLock::new();
fn cached_color_depth() -> palette::ColorDepth {
*COLOR_DEPTH.get_or_init(palette::ColorDepth::detect)
}