use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::tui::chat::{ChatMessage, Role};
use crate::tui::chat_thinking::{ThinkingView, dur};
use crate::tui::theme;
const PREVIEW_ROWS: usize = 6;
fn rule_answer() -> Span<'static> {
Span::styled("▏", theme::brand_cyan())
}
fn rule_think() -> Span<'static> {
Span::styled("┆", theme::dim())
}
fn model_body() -> Style {
theme::text().bg(theme::BG_PANEL.color())
}
pub(super) fn wrap_rows(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let mut rows = Vec::new();
for logical in text.split('\n') {
let (mut cur, mut cur_w) = (String::new(), 0usize);
for word in logical.split_inclusive(' ') {
let w = UnicodeWidthStr::width(word);
if cur_w + w > width && !cur.is_empty() {
rows.push(std::mem::take(&mut cur));
cur_w = 0;
}
if w > width {
for ch in word.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if cur_w + cw > width {
rows.push(std::mem::take(&mut cur));
cur_w = 0;
}
cur.push(ch);
cur_w += cw;
}
} else {
cur.push_str(word);
cur_w += w;
}
}
rows.push(cur);
}
rows
}
fn clip(s: &str, width: usize) -> String {
if UnicodeWidthStr::width(s) <= width {
return s.to_string();
}
let mut out = String::new();
let mut w = 0usize;
for ch in s.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw + 1 > width {
break;
}
out.push(ch);
w += cw;
}
out.push('…');
out
}
fn footer(m: &ChatMessage, wide: bool) -> Option<String> {
if m.ttft_ms.is_none() && m.tok_per_s.is_none() {
return None;
}
let mut parts: Vec<String> = Vec::new();
match m.ttft_ms {
Some(v) => parts.push(format!("ttft {}", dur(v / 1000.0))),
None => parts.push("ttft —".into()),
}
if wide
&& !m.reasoning.is_empty()
&& let Some(v) = m.answer_ttft_ms
{
parts.push(format!("answer {}", dur(v / 1000.0)));
}
if m.reasoning.tokens > 0 {
parts.push(format!("{} think + {} tok", m.reasoning.tokens, m.tokens));
} else {
parts.push(format!("{} tok", m.tokens));
}
if let Some(tps) = m.tok_per_s.filter(|t| *t > 0.0) {
parts.push(format!("{:.1} ms/tok", 1000.0 / tps));
}
Some(parts.join(" · "))
}
fn thinking_rows(
rows: &mut Vec<Vec<Span<'static>>>,
m: &ChatMessage,
live: bool,
view: ThinkingView,
tick: u64,
body_w: usize,
) {
if m.reasoning.is_empty() || view == ThinkingView::Hidden {
return;
}
let secs = m.reasoning.seconds().unwrap_or(0.0);
let (lead, lead_style, rest) = if live {
(
format!(" {} ", theme::SPINNER[(tick % 10) as usize]),
theme::brand_cyan(),
format!("thinking {}", dur(secs)),
)
} else {
let caret = if view == ThinkingView::Expanded {
" ▾ "
} else {
" ▸ "
};
let n = m.reasoning.tokens;
let rest = if body_w >= 34 {
format!("thought for {} · {n} tokens", dur(secs))
} else {
format!("thought {} · {n} tok", dur(secs))
};
(caret.to_string(), theme::dim(), rest)
};
let room = body_w.saturating_sub(UnicodeWidthStr::width(lead.as_str()) + 1);
rows.push(vec![
rule_think(),
Span::styled(lead, lead_style),
Span::styled(clip(&rest, room), theme::text2()),
]);
let limit = match (view, live) {
(ThinkingView::Expanded, _) => usize::MAX,
(_, true) => PREVIEW_ROWS,
(_, false) => return,
};
let wrapped = wrap_rows(&m.reasoning.text, body_w.saturating_sub(1));
let skip = wrapped.len().saturating_sub(limit);
for r in wrapped.into_iter().skip(skip) {
rows.push(vec![
rule_think(),
Span::styled(format!(" {r}"), theme::text2()),
]);
}
}
fn answer_rows(rows: &mut Vec<Vec<Span<'static>>>, m: &ChatMessage, is_tip: bool, body_w: usize) {
if m.is_answerless() {
let msg = if m.reasoning.is_empty() {
"(no answer — the reply produced nothing)"
} else {
"(no answer — the model stopped after thinking)"
};
rows.push(vec![rule_answer(), Span::styled(msg, theme::warn())]);
return;
}
if m.text.is_empty() && is_tip && !rows.is_empty() {
return;
}
for r in wrap_rows(&m.text, body_w) {
rows.push(vec![rule_answer(), Span::styled(r, model_body())]);
}
}
pub(super) fn message_lines(
m: &ChatMessage,
is_tip: bool,
view: ThinkingView,
tick: u64,
body_w: usize,
) -> Vec<Line<'static>> {
let mut rows: Vec<Vec<Span<'static>>> = Vec::new();
match m.role {
Role::User => {
for r in wrap_rows(&m.text, body_w) {
rows.push(vec![Span::raw(""), Span::styled(r, theme::text())]);
}
}
Role::Model => {
let live = is_tip && m.text.is_empty();
thinking_rows(&mut rows, m, live, view, tick, body_w);
answer_rows(&mut rows, m, is_tip, body_w);
if is_tip && let Some(last) = rows.last_mut() {
last.push(Span::styled("▍", theme::brand_cyan()));
}
if let Some(f) = footer(m, body_w >= 60) {
for r in wrap_rows(&f, body_w) {
rows.push(vec![Span::raw(""), Span::styled(r, theme::dim())]);
}
}
}
}
let (glyph, gstyle) = match m.role {
Role::User => ("❯ ", theme::brand_purple().add_modifier(Modifier::BOLD)),
Role::Model => ("⬢ ", theme::brand_cyan()),
};
rows.into_iter()
.enumerate()
.map(|(i, mut spans)| {
let mut line = vec![if i == 0 {
Span::styled(glyph, gstyle)
} else {
Span::styled(" ", Style::default())
}];
line.append(&mut spans);
Line::from(line)
})
.collect()
}
#[cfg(test)]
#[path = "chat_lines_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "chat_wide_tests.rs"]
mod wide_tests;