use ratatui::Frame;
use ratatui::layout::{Margin, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph};
use uuid::Uuid;
use crate::entities::chat::FeedView;
use crate::entities::message::{Message, MessageRole};
use crate::entities::profile::CharacterNames;
use crate::features::tools::present::{self, ToolBlock};
use crate::shared::i18n::Locale;
use crate::shared::markdown;
use crate::shared::theme::Palette;
use crate::shared::ui::render_scrollbar;
use crate::shared::wrap;
const RAIL: &str = "▌ ";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FeedRole {
User,
Assistant,
System,
Note,
}
#[derive(Debug, Clone)]
pub struct FeedToolCall {
pub name: String,
pub arguments: String,
pub result: String,
pub text_offset: usize,
pub images: usize,
pub call_id: Option<String>,
pub running: bool,
}
#[derive(Debug, Clone)]
pub struct FeedMessage {
pub role: FeedRole,
pub text: String,
pub thoughts: String,
pub tools: Vec<FeedToolCall>,
pub streaming: bool,
pub message_ids: Vec<Uuid>,
pub model: Option<String>,
}
impl FeedMessage {
pub fn system(text: impl Into<String>) -> Self {
Self {
role: FeedRole::System,
text: text.into(),
thoughts: String::new(),
tools: Vec::new(),
streaming: false,
message_ids: Vec::new(),
model: None,
}
}
pub fn note(text: impl Into<String>) -> Self {
Self {
role: FeedRole::Note,
text: text.into(),
thoughts: String::new(),
tools: Vec::new(),
streaming: false,
message_ids: Vec::new(),
model: None,
}
}
pub fn from_message(msg: &Message) -> Option<Self> {
let role = match msg.role {
MessageRole::User => FeedRole::User,
MessageRole::Assistant => FeedRole::Assistant,
MessageRole::System => FeedRole::Note,
MessageRole::Tool => return None,
};
let off = msg.text.len();
let tools = msg
.tool_calls
.iter()
.filter(|tc| !crate::features::tools::control::is_control_tool(&tc.name))
.map(|tc| FeedToolCall {
name: tc.name.clone(),
arguments: tc.arguments.to_string(),
result: tc.result.clone().unwrap_or_default(),
text_offset: off,
images: tc.images,
call_id: None,
running: false,
})
.collect();
Some(Self {
role,
text: msg.text.clone(),
thoughts: msg.thoughts.clone().unwrap_or_default(),
tools,
streaming: false,
message_ids: vec![msg.id],
model: msg
.metadata
.as_ref()
.and_then(|meta| meta.model.clone())
.filter(|m| !m.is_empty()),
})
}
pub fn from_messages(messages: &[Message]) -> Vec<Self> {
let mut out: Vec<Self> = Vec::new();
for msg in messages {
let Some(fm) = Self::from_message(msg) else {
continue;
};
if fm.role == FeedRole::Assistant
&& !msg.new_bubble
&& let Some(last) = out.last_mut()
&& last.role == FeedRole::Assistant
{
Self::merge_round(last, fm);
continue;
}
out.push(fm);
}
out
}
fn merge_round(last: &mut Self, mut fm: Self) {
if !fm.text.is_empty() {
if !last.text.is_empty() {
last.text.push_str("\n\n");
}
last.text.push_str(&fm.text);
}
let base = last.text.len();
for mut tc in fm.tools.drain(..) {
tc.text_offset = base;
last.tools.push(tc);
}
if !fm.thoughts.is_empty() {
if !last.thoughts.is_empty() {
last.thoughts.push('\n');
}
last.thoughts.push_str(&fm.thoughts);
}
last.message_ids.append(&mut fm.message_ids);
if last.model.is_none() {
last.model = fm.model.take();
}
}
}
pub struct MessageFeed {
scroll: usize,
follow: bool,
view: FeedView,
scrolled: bool,
table_row_separators: bool,
render_mermaid: bool,
show_model_name: bool,
role_names: CharacterNames,
compaction: Option<(Uuid, String)>,
known_chats: Vec<Uuid>,
engine_missing: bool,
link_hits: Vec<LinkHit>,
cache: Vec<CachedBlock>,
cache_key: Option<CacheKey>,
pending_focus: Option<usize>,
anchor: Option<usize>,
marker: Option<usize>,
highlight: Option<String>,
highlight_scope: HighlightScope,
matches: Vec<usize>,
current_match: usize,
scroll_to_match: bool,
cache_reset: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum HighlightScope {
#[default]
MarkedOnly,
WholeFeed,
}
#[derive(PartialEq)]
struct CacheKey {
width: usize,
palette: Palette,
view: FeedView,
table_row_separators: bool,
render_mermaid: bool,
show_model_name: bool,
lang: crate::shared::i18n::Lang,
role_names: CharacterNames,
marker: Option<usize>,
chat_book: u64,
compaction: Option<(Uuid, String)>,
}
struct CachedBlock {
fingerprint: u64,
lines: Vec<Line<'static>>,
links: Vec<Uuid>,
content_from: usize,
}
impl Default for MessageFeed {
fn default() -> Self {
Self::new()
}
}
impl MessageFeed {
pub fn new() -> Self {
Self {
scroll: 0,
follow: true,
view: FeedView::default(),
scrolled: false,
table_row_separators: false,
render_mermaid: true,
show_model_name: false,
role_names: CharacterNames::default(),
compaction: None,
known_chats: Vec::new(),
engine_missing: false,
link_hits: Vec::new(),
cache: Vec::new(),
cache_key: None,
pending_focus: None,
anchor: None,
marker: None,
highlight: None,
highlight_scope: HighlightScope::default(),
matches: Vec::new(),
current_match: 0,
scroll_to_match: false,
cache_reset: false,
}
}
pub fn set_role_names(&mut self, names: CharacterNames) {
self.role_names = names;
}
pub fn set_table_row_separators(&mut self, on: bool) {
self.table_row_separators = on;
}
pub fn set_render_mermaid(&mut self, on: bool) {
self.render_mermaid = on;
}
pub fn set_show_model_name(&mut self, on: bool) {
self.show_model_name = on;
}
pub fn set_compaction(&mut self, compaction: Option<(Uuid, String)>) {
self.compaction = compaction;
}
#[cfg(test)]
pub fn known_chats(&self) -> &[Uuid] {
&self.known_chats
}
pub fn set_known_chats(&mut self, ids: Vec<Uuid>) {
self.known_chats = ids;
}
pub fn set_engine_missing(&mut self, missing: bool) {
self.engine_missing = missing;
}
fn chat_book(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
self.known_chats.hash(&mut h);
h.finish()
}
pub fn chat_links(&self) -> Vec<Uuid> {
let mut seen: Vec<Uuid> = Vec::new();
for block in self.cache.iter().rev() {
for id in &block.links {
if !seen.contains(id) {
seen.push(*id);
}
}
}
seen
}
pub fn chat_link_at(&self, col: u16, row: u16) -> Option<Uuid> {
self.link_hits
.iter()
.find(|h| h.row == row && (h.start..h.end).contains(&col))
.map(|h| h.chat)
}
fn boundary_index(&self, messages: &[FeedMessage]) -> Option<usize> {
let (id, _) = self.compaction.as_ref()?;
messages.iter().position(|m| m.message_ids.contains(id))
}
pub fn take_scrolled(&mut self) -> bool {
std::mem::take(&mut self.scrolled)
}
pub fn toggle_thoughts(&mut self) {
self.view.thoughts = !self.view.thoughts;
}
pub fn toggle_tools(&mut self) {
self.view.tools = !self.view.tools;
}
pub fn view(&self) -> FeedView {
self.view
}
pub fn set_view(&mut self, view: FeedView) {
self.view = view;
}
pub fn scroll_up(&mut self, lines: usize) {
self.scroll = self.scroll.saturating_sub(lines);
self.follow = false;
self.scrolled = true;
self.release_anchor();
}
pub fn scroll_down(&mut self, lines: usize) {
self.scroll = self.scroll.saturating_add(lines);
self.scrolled = true;
self.release_anchor();
}
pub fn scroll_to_bottom(&mut self) {
self.follow = true;
self.release_anchor();
}
pub fn scroll_to_bottom_if_following(&mut self) {
if self.follow {
self.scroll_to_bottom();
}
}
pub fn focus_message(
&mut self,
messages: &[FeedMessage],
id: Uuid,
highlight: Option<&str>,
) -> bool {
let Some(idx) = messages.iter().position(|m| m.message_ids.contains(&id)) else {
return false;
};
self.pending_focus = Some(idx);
self.anchor = Some(idx);
self.marker = Some(idx);
self.highlight = highlight.filter(|q| !q.is_empty()).map(str::to_owned);
true
}
pub fn set_search(&mut self, query: Option<&str>) {
self.highlight = query.filter(|q| !q.is_empty()).map(str::to_owned);
self.highlight_scope = HighlightScope::WholeFeed;
self.current_match = 0;
self.scroll_to_match = self.highlight.is_some();
}
pub fn clear_search(&mut self) {
self.highlight = None;
self.highlight_scope = HighlightScope::MarkedOnly;
self.matches.clear();
self.current_match = 0;
self.scroll_to_match = false;
}
pub fn match_position(&self) -> Option<(usize, usize)> {
(!self.matches.is_empty()).then(|| (self.current_match + 1, self.matches.len()))
}
pub fn next_match(&mut self) {
if self.matches.is_empty() {
return;
}
self.current_match = (self.current_match + 1) % self.matches.len();
self.scroll_to_match = true;
}
pub fn prev_match(&mut self) {
if self.matches.is_empty() {
return;
}
self.current_match = (self.current_match + self.matches.len() - 1) % self.matches.len();
self.scroll_to_match = true;
}
pub fn clear_focus(&mut self) {
self.pending_focus = None;
self.anchor = None;
self.marker = None;
self.highlight = None;
}
fn release_anchor(&mut self) {
self.pending_focus = None;
self.anchor = None;
}
fn take_focus_target(&mut self) -> Option<usize> {
let rewrapped = std::mem::take(&mut self.cache_reset);
if let Some(idx) = self.pending_focus.take() {
return Some(idx);
}
if rewrapped && !self.follow {
self.anchor
} else {
None
}
}
#[cfg(test)]
pub(crate) fn is_following(&self) -> bool {
self.follow
}
#[cfg(test)]
pub(crate) fn scroll_row(&self) -> usize {
self.scroll
}
#[cfg(test)]
pub(crate) fn anchor(&self) -> Option<usize> {
self.anchor
}
#[cfg(test)]
pub(crate) fn marker(&self) -> Option<usize> {
self.marker
}
#[cfg(test)]
pub(crate) fn link_hit_for_test(&self, n: usize) -> Option<(u16, u16, u16)> {
self.link_hits.get(n).map(|h| (h.start, h.row, h.end))
}
#[cfg(test)]
pub(crate) fn highlight(&self) -> Option<&str> {
self.highlight.as_deref()
}
#[allow(clippy::too_many_arguments)]
pub fn render(
&mut self,
frame: &mut Frame,
area: Rect,
title: &str,
meta: &str,
messages: &[FeedMessage],
palette: &Palette,
loc: &'static Locale,
) {
let glyphs = palette.glyphs();
let marker = format!(" {} ", glyphs.title_marker);
let meta_cell = (!meta.is_empty()).then(|| format!(" {meta} "));
let budget = (area.width as usize).saturating_sub(
2 + wrap::str_width(&marker) + 1 + meta_cell.as_deref().map_or(0, wrap::str_width),
);
let (title, _) = wrap::truncate_to_width(title, budget);
let mut block = Block::default()
.borders(Borders::ALL)
.border_type(glyphs.border)
.border_style(palette.border_style(false))
.title(Line::from(vec![
Span::styled(marker, palette.muted_style()),
Span::styled(format!("{title} "), Style::new().fg(palette.text)),
]));
if let Some(cell) = meta_cell {
block =
block.title(Line::from(Span::styled(cell, palette.muted_style())).right_aligned());
}
let inner = block.inner(area);
frame.render_widget(&block, area);
let view_w = inner.width.max(1) as usize;
let unwrapped = self.build_lines(messages, palette, view_w, loc);
let target = self.take_focus_target();
let start = target.and_then(|idx| {
(idx < self.cache.len())
.then(|| self.cache.iter().take(idx).map(|b| b.lines.len()).sum())
});
let match_line = std::mem::take(&mut self.scroll_to_match)
.then(|| self.matches.get(self.current_match).copied())
.flatten();
let mut lines: Vec<Line> = Vec::with_capacity(unwrapped.len());
let mut target_row: Option<usize> = None;
let mut match_row: Option<usize> = None;
for (i, line) in unwrapped.iter().enumerate() {
if start == Some(i) {
target_row = Some(lines.len());
}
if match_line == Some(i) {
match_row = Some(lines.len());
}
lines.extend(wrap::wrap_line(line, view_w));
}
if let Some(row) = match_row {
self.scroll = row;
self.follow = false;
self.scrolled = true;
}
if let Some(row) = target_row {
self.scroll = row;
self.follow = false;
self.scrolled = true;
}
let total = lines.len();
let view_h = inner.height.max(1) as usize;
let max_scroll = total.saturating_sub(view_h);
if self.follow {
self.scroll = max_scroll;
} else if self.scroll >= max_scroll {
self.scroll = max_scroll;
self.follow = true;
}
self.link_hits = visible_link_hits(&lines, inner, self.scroll, &self.known_chats);
let paragraph = Paragraph::new(Text::from(lines)).scroll((self.scroll as u16, 0));
frame.render_widget(paragraph, inner);
render_scrollbar(
frame,
area.inner(Margin::new(0, 1)),
total,
view_h,
self.scroll,
false,
palette,
);
}
fn empty_placeholder(&self, palette: &Palette, loc: &'static Locale) -> Vec<Line<'static>> {
let line =
|key: &str| Line::from(Span::styled(loc.t(key).to_string(), palette.muted_style()));
if !self.engine_missing {
return vec![line("ui.feed.empty")];
}
vec![
line("ui.feed.no_engine.title"),
Line::default(),
line("ui.feed.no_engine.cloud"),
line("ui.feed.no_engine.local"),
line("ui.feed.no_engine.demo"),
]
}
fn build_lines(
&mut self,
messages: &[FeedMessage],
palette: &Palette,
width: usize,
loc: &'static Locale,
) -> Vec<Line<'static>> {
if messages.is_empty() {
return self.empty_placeholder(palette, loc);
}
let key = CacheKey {
width,
palette: *palette,
view: self.view,
table_row_separators: self.table_row_separators,
render_mermaid: self.render_mermaid,
show_model_name: self.show_model_name,
lang: loc.lang(),
role_names: self.role_names.clone(),
marker: self.marker,
chat_book: self.chat_book(),
compaction: self.compaction.clone(),
};
if self.cache_key.as_ref() != Some(&key) {
self.cache.clear();
self.cache_key = Some(key);
self.cache_reset = true;
}
self.cache.truncate(messages.len());
let opts = markdown::RenderOpts {
table_row_separators: self.table_row_separators,
render_mermaid: self.render_mermaid,
..Default::default()
};
let boundary = self.boundary_index(messages);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut found: Vec<usize> = Vec::new();
for (idx, item) in messages.iter().enumerate() {
self.refresh_cached_block(idx, item, palette, width, opts, loc, boundary == Some(idx));
let at = lines.len();
lines.extend(self.cache[idx].lines.iter().cloned());
if let Some(query) = self.highlight.as_deref()
&& (self.highlight_scope == HighlightScope::WholeFeed || self.marker == Some(idx))
{
let from = at + self.cache[idx].content_from;
highlight_block_tail(&mut lines, from, query, palette.accent, &mut found);
}
}
self.matches = found;
if self.current_match >= self.matches.len() {
self.current_match = 0;
}
lines
}
#[allow(clippy::too_many_arguments)]
fn refresh_cached_block(
&mut self,
idx: usize,
item: &FeedMessage,
palette: &Palette,
width: usize,
opts: markdown::RenderOpts,
loc: &'static Locale,
at_boundary: bool,
) {
let fp = message_fingerprint(item);
let hit = self.cache.get(idx).is_some_and(|c| c.fingerprint == fp);
if !hit {
let summary = at_boundary
.then(|| self.compaction.as_ref().map(|(_, s)| s.as_str()))
.flatten();
let built = build_message_block(
item,
palette,
width,
self.view,
opts,
loc,
&self.role_names,
self.marker == Some(idx),
summary,
&self.known_chats,
self.show_model_name,
);
let cb = CachedBlock {
fingerprint: fp,
lines: built.lines,
links: built.links,
content_from: built.content_from,
};
if idx < self.cache.len() {
self.cache[idx] = cb;
} else {
self.cache.push(cb);
}
}
}
}
fn highlight_block_tail(
lines: &mut [Line<'static>],
from: usize,
query: &str,
accent: Color,
found: &mut Vec<usize>,
) {
for (i, line) in lines[from..].iter_mut().enumerate() {
for _ in 0..highlight_line(line, query, accent) {
found.push(from + i);
}
}
}
#[allow(clippy::too_many_arguments)]
fn build_message_block(
item: &FeedMessage,
palette: &Palette,
width: usize,
view: FeedView,
opts: markdown::RenderOpts,
loc: &'static Locale,
names: &CharacterNames,
marked: bool,
compaction: Option<&str>,
known_chats: &[Uuid],
show_model: bool,
) -> BuiltBlock {
let inner = width.saturating_sub(RAIL.chars().count()).max(1);
let rail = if marked {
palette.accent
} else {
match item.role {
FeedRole::User => palette.user,
FeedRole::Assistant => palette.assistant,
FeedRole::System | FeedRole::Note => palette.muted,
}
};
let mut body: Vec<Line<'static>> = Vec::new();
let mut content_from = 0usize;
let glyphs = palette.glyphs();
match item.role {
FeedRole::User => {
body.push(role_header(
&format!(
"{} {}",
glyphs.user_icon,
role_name(names.user_name(), "ui.feed.role.user", loc)
),
palette.user_soft,
None,
palette,
));
content_from = body.len();
push_body(&mut body, item, palette, inner, opts);
}
FeedRole::Assistant => {
body.push(role_header(
&format!(
"{} {}",
glyphs.assistant_icon,
role_name(names.assistant_name(), "ui.feed.role.assistant", loc)
),
palette.assistant_soft,
item.model.as_deref().filter(|_| show_model),
palette,
));
content_from = body.len();
push_thoughts(&mut body, &item.thoughts, view.thoughts, palette, loc);
push_assistant_body(&mut body, item, palette, inner, view.tools, opts, loc);
}
FeedRole::System => {
body.push(role_header(
&format!(
"{} {}",
glyphs.system_icon,
role_name(names.system_name(), "ui.feed.role.system", loc)
),
palette.muted,
None,
palette,
));
content_from = body.len();
push_body(&mut body, item, palette, inner, opts);
}
FeedRole::Note => push_body(&mut body, item, palette, inner, opts),
}
let body_ends_blank = body
.last()
.map(|l| l.spans.iter().all(|s| s.content.trim().is_empty()))
.unwrap_or(false);
let mut out: Vec<Line<'static>> = Vec::new();
if let Some(summary) = compaction {
push_compaction_boundary(&mut out, summary, view.thoughts, width, palette, loc);
}
let mut content_row = None;
let mut links: Vec<Uuid> = Vec::new();
for (i, mut line) in body.into_iter().enumerate() {
if i == content_from {
content_row = Some(out.len());
}
links.extend(style_chat_links(&mut line, known_chats, palette));
for wrapped in wrap::wrap_line(&line, inner) {
out.push(prepend_rail(wrapped, rail));
}
}
let content_from = content_row.unwrap_or(out.len());
if !body_ends_blank {
out.push(Line::from(""));
}
links.dedup();
BuiltBlock {
lines: out,
content_from,
links,
}
}
struct BuiltBlock {
lines: Vec<Line<'static>>,
content_from: usize,
links: Vec<Uuid>,
}
fn style_chat_links(line: &mut Line<'static>, known: &[Uuid], palette: &Palette) -> Vec<Uuid> {
let (text, found) = chat_links_in(line, known);
if found.is_empty() {
return Vec::new();
}
let ranges: Vec<std::ops::Range<usize>> = found.iter().map(|l| l.range.clone()).collect();
restyle_ranges(line, &text, &ranges, |style| {
style.fg(palette.accent).add_modifier(Modifier::UNDERLINED)
});
found.into_iter().map(|l| l.chat).collect()
}
fn chat_links_in(
line: &Line<'static>,
known: &[Uuid],
) -> (String, Vec<crate::features::chat_links::ChatLink>) {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let found = crate::features::chat_links::find_refs(&text, known);
(text, found)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct LinkHit {
row: u16,
start: u16,
end: u16,
chat: Uuid,
}
fn visible_link_hits(
lines: &[Line<'static>],
inner: Rect,
scroll: usize,
known: &[Uuid],
) -> Vec<LinkHit> {
if known.is_empty() {
return Vec::new();
}
let mut hits = Vec::new();
for (i, line) in lines
.iter()
.skip(scroll)
.take(inner.height as usize)
.enumerate()
{
let (text, found) = chat_links_in(line, known);
for link in found {
let chars: Vec<char> = text[..link.range.start].chars().collect();
let before = wrap::display_width(&chars) as u16;
let width = wrap::display_width(&text[link.range.clone()].chars().collect::<Vec<_>>());
let start = inner.x.saturating_add(before);
hits.push(LinkHit {
row: inner.y.saturating_add(i as u16),
start,
end: start.saturating_add(width as u16),
chat: link.chat,
});
}
}
hits
}
fn highlight_line(line: &mut Line<'static>, query: &str, color: Color) -> usize {
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
let ranges = crate::features::chat_search::match_ranges(&text, query);
if ranges.is_empty() {
return 0;
}
restyle_ranges(line, &text, &ranges, |style| style.fg(color));
ranges.len()
}
fn restyle_ranges(
line: &mut Line<'static>,
text: &str,
ranges: &[std::ops::Range<usize>],
patch: impl Fn(Style) -> Style,
) {
let spans = std::mem::take(&mut line.spans);
let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len() + ranges.len() * 2);
let mut at = 0usize;
for span in spans {
let (start, end) = (at, at + span.content.len());
at = end;
let cuts = span_cut_points(start, end, ranges);
for w in cuts.windows(2) {
let (s, e) = (w[0], w[1]);
let inside = ranges.iter().any(|r| r.start <= s && e <= r.end);
let style = if inside {
patch(span.style)
} else {
span.style
};
out.push(Span::styled(text[s..e].to_string(), style));
}
}
line.spans = out;
}
fn span_cut_points(start: usize, end: usize, ranges: &[std::ops::Range<usize>]) -> Vec<usize> {
let mut cuts: Vec<usize> = vec![start];
for r in ranges {
for b in [r.start, r.end] {
if b > start && b < end {
cuts.push(b);
}
}
}
cuts.push(end);
cuts.dedup();
cuts
}
fn message_fingerprint(item: &FeedMessage) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
let role_tag: u8 = match item.role {
FeedRole::User => 0,
FeedRole::Assistant => 1,
FeedRole::Note => 2,
FeedRole::System => 3,
};
role_tag.hash(&mut h);
item.text.hash(&mut h);
item.thoughts.hash(&mut h);
item.streaming.hash(&mut h);
item.model.hash(&mut h);
item.tools.len().hash(&mut h);
for tc in &item.tools {
tc.name.hash(&mut h);
tc.arguments.hash(&mut h);
tc.result.hash(&mut h);
tc.text_offset.hash(&mut h);
tc.running.hash(&mut h);
}
h.finish()
}
fn role_name(custom: Option<&str>, key: &str, loc: &'static Locale) -> String {
match custom {
Some(name) => name.to_uppercase(),
None => loc.t(key).to_string(),
}
}
fn role_header(text: &str, color: Color, model: Option<&str>, palette: &Palette) -> Line<'static> {
let mut spans = vec![Span::styled(
text.to_string(),
Style::new().fg(color).add_modifier(Modifier::BOLD),
)];
if let Some(model) = model.filter(|m| !m.is_empty()) {
spans.push(Span::styled(format!(" {model}"), palette.muted_style()));
}
Line::from(spans)
}
fn prepend_rail(line: Line<'static>, rail: Color) -> Line<'static> {
let mut spans = Vec::with_capacity(line.spans.len() + 1);
spans.push(Span::styled(RAIL.to_string(), Style::new().fg(rail)));
for span in line.spans {
let merged = line.style.patch(span.style);
spans.push(Span::styled(span.content, merged));
}
let mut out = Line::from(spans);
out.alignment = line.alignment;
out
}
fn push_compaction_boundary(
lines: &mut Vec<Line<'static>>,
summary: &str,
expanded: bool,
width: usize,
palette: &Palette,
loc: &'static Locale,
) {
let muted = palette.muted_style();
let glyphs = palette.glyphs();
let marker = if expanded {
glyphs.expanded
} else {
glyphs.collapsed
};
let mut spans = vec![
Span::styled(format!("{marker} "), muted),
Span::styled(
loc.t("ui.feed.compacted").to_string(),
muted.add_modifier(Modifier::ITALIC),
),
];
if !expanded {
spans.push(Span::styled(" · ", muted));
spans.push(palette.keycap("Ctrl+T"));
}
let used: usize = spans
.iter()
.map(|s| wrap::display_width(&s.content.chars().collect::<Vec<_>>()))
.sum();
if let Some(pad) = width.checked_sub(used + 1).filter(|p| *p > 0) {
spans.push(Span::styled(format!(" {}", "─".repeat(pad)), muted));
}
lines.push(Line::from(spans));
if !expanded || summary.is_empty() {
return;
}
for t in summary.lines() {
lines.push(Line::from(Span::styled(
format!("│ {t}"),
muted.add_modifier(Modifier::ITALIC),
)));
}
lines.push(Line::from(""));
}
fn push_thoughts(
lines: &mut Vec<Line<'static>>,
thoughts: &str,
expanded: bool,
palette: &Palette,
loc: &'static Locale,
) {
if thoughts.is_empty() {
return;
}
let muted = palette.muted_style();
let glyphs = palette.glyphs();
if !expanded {
let count = thoughts.lines().count();
lines.push(Line::from(vec![
Span::styled(format!("{} ", glyphs.collapsed), muted),
Span::styled(
loc.t("ui.feed.thoughts").to_string(),
muted.add_modifier(Modifier::ITALIC),
),
Span::styled(
loc.tf("ui.feed.thoughts_lines", &[("n", &count.to_string())]),
muted,
),
palette.keycap("Ctrl+T"),
]));
return;
}
lines.push(Line::from(Span::styled(
format!("{} {}", glyphs.expanded, loc.t("ui.feed.thoughts")),
muted.add_modifier(Modifier::ITALIC),
)));
for t in thoughts.lines() {
lines.push(Line::from(Span::styled(
format!("│ {t}"),
muted.add_modifier(Modifier::ITALIC),
)));
}
}
fn push_assistant_body(
lines: &mut Vec<Line<'static>>,
item: &FeedMessage,
palette: &Palette,
width: usize,
tools_expanded: bool,
opts: markdown::RenderOpts,
loc: &'static Locale,
) {
let text = item.text.as_str();
let mut pos = 0usize;
let mut produced = false;
for tool in &item.tools {
let off = clamp_boundary(text, tool.text_offset.min(text.len())).max(pos);
if off > pos {
push_markdown_fragment(lines, &text[pos..off], palette, width, opts);
}
ensure_blank_line(lines);
push_tool(lines, tool, palette, width, tools_expanded, opts, loc);
ensure_blank_line(lines);
produced = true;
pos = off;
}
if pos < text.len() && push_markdown_fragment(lines, &text[pos..], palette, width, opts) {
produced = true;
}
if !produced && item.streaming {
lines.push(Line::from("…").dim());
}
}
fn ensure_blank_line(lines: &mut Vec<Line<'static>>) {
let blank = lines
.last()
.map(|l| l.spans.iter().all(|s| s.content.trim().is_empty()))
.unwrap_or(true);
if !blank {
lines.push(Line::from(""));
}
}
fn push_markdown_fragment(
lines: &mut Vec<Line<'static>>,
fragment: &str,
palette: &Palette,
width: usize,
opts: markdown::RenderOpts,
) -> bool {
if fragment.trim().is_empty() {
return false;
}
let rendered = markdown::render_with(fragment, width, palette, opts);
lines.extend(rendered.lines);
true
}
fn push_tool(
lines: &mut Vec<Line<'static>>,
tool: &FeedToolCall,
palette: &Palette,
width: usize,
expanded: bool,
opts: markdown::RenderOpts,
loc: &'static Locale,
) {
let head_style = Style::default()
.fg(palette.tool_soft)
.add_modifier(Modifier::BOLD);
let detail = if expanded {
present::ArgDetail::Full
} else {
present::ArgDetail::Compact
};
let p = present::present(&tool.name, &tool.arguments, &tool.result, detail);
let header = match &p.header_suffix {
Some(suffix) => format!("{}({suffix})", tool.name),
None => tool.name.clone(),
};
let glyphs = palette.glyphs();
push_wrapped(
lines,
glyphs.tool_head,
glyphs.tool_cont,
&header,
width,
head_style,
);
let muted = palette.muted_style();
if tool.running {
let chip = Span::styled(
format!("{} {}", glyphs.busy, loc.t("ui.feed.tool_running")),
muted.add_modifier(Modifier::ITALIC),
);
push_trailing(lines, vec![chip], glyphs.tool_cont, width);
}
if tool.images > 0 {
let chip = Span::styled(
loc.tf("ui.feed.tool_images", &[("n", &tool.images.to_string())]),
muted,
);
push_trailing(lines, vec![chip], glyphs.tool_cont, width);
}
if !expanded {
if p.args.is_empty() && p.result.is_empty() {
return;
}
let pill = vec![
Span::styled(format!("{} ", glyphs.collapsed), muted),
Span::styled(
loc.t("ui.feed.tool_details").to_string(),
muted.add_modifier(Modifier::ITALIC),
),
Span::styled(" · ", muted),
palette.keycap("Ctrl+O"),
];
push_trailing(lines, pill, glyphs.tool_cont, width);
return;
}
for block in &p.args {
push_block(lines, block, palette, width, false, opts, loc);
}
if !p.args.is_empty() && !p.result.is_empty() {
lines.push(Line::from(Span::styled(
"│".to_string(),
Style::default().fg(palette.muted),
)));
}
for block in &p.result {
push_block(lines, block, palette, width, true, opts, loc);
}
}
fn push_block(
lines: &mut Vec<Line<'static>>,
block: &ToolBlock,
palette: &Palette,
width: usize,
is_result: bool,
opts: markdown::RenderOpts,
loc: &'static Locale,
) {
let gutter_style = Style::default().fg(palette.muted);
match block {
ToolBlock::Plain(text) => {
let (first, cont) = if is_result {
("└ ", " ")
} else {
("│ ", "│ ")
};
push_wrapped(lines, first, cont, text, width, gutter_style);
}
ToolBlock::Code { lang, text } => {
let hl = markdown::highlight_code(text, lang, palette);
push_gutter_lines(lines, hl, "│ ", "│ ", gutter_style, width);
}
ToolBlock::Markdown(text) => {
let body_w = width.saturating_sub(2).max(1);
let rendered = markdown::render_with(text, body_w, palette, opts);
push_gutter_lines(lines, rendered.lines, "└ ", " ", gutter_style, width);
}
ToolBlock::Console(c) => push_console(lines, c, palette, width, loc),
}
}
fn push_console(
lines: &mut Vec<Line<'static>>,
console: &present::Console,
palette: &Palette,
width: usize,
loc: &'static Locale,
) {
let label = Style::default().fg(palette.muted);
let out_style = Style::default().fg(palette.text);
let err_style = Style::default().fg(palette.error);
if !console.stdout.trim().is_empty() {
push_wrapped(lines, "└ ", " ", "stdout", width, label);
push_wrapped(lines, "│ ", "│ ", &console.stdout, width, out_style);
}
if !console.stderr.trim().is_empty() {
push_wrapped(lines, "└ ", " ", "stderr", width, err_style);
push_wrapped(lines, "│ ", "│ ", &console.stderr, width, err_style);
}
if let Some(code) = console.exit {
let warn = Style::default().fg(palette.warning);
push_wrapped(
lines,
"└ ",
" ",
&format!("{} {code}", loc.t("ui.feed.exit_code")),
width,
warn,
);
}
if !console.files.trim().is_empty() {
push_wrapped(lines, "└ ", " ", "files", width, label);
push_wrapped(lines, "│ ", "│ ", &console.files, width, out_style);
}
}
fn push_gutter_lines(
lines: &mut Vec<Line<'static>>,
src: Vec<Line<'static>>,
first: &str,
cont: &str,
gutter_style: Style,
width: usize,
) {
let first_w = wrap::display_width(&first.chars().collect::<Vec<_>>());
let cont_w = wrap::display_width(&cont.chars().collect::<Vec<_>>());
let body_w = width.saturating_sub(first_w.max(cont_w)).max(1);
let mut first_row = true;
for line in src {
let line_style = line.style;
let align = line.alignment;
for wrapped in wrap::wrap_line(&line, body_w) {
let prefix = if first_row { first } else { cont };
first_row = false;
let mut spans = Vec::with_capacity(wrapped.spans.len() + 1);
spans.push(Span::styled(prefix.to_string(), gutter_style));
for span in wrapped.spans {
spans.push(Span::styled(span.content, line_style.patch(span.style)));
}
let mut out = Line::from(spans);
out.alignment = align;
lines.push(out);
}
}
}
fn push_wrapped(
lines: &mut Vec<Line<'static>>,
first: &str,
cont: &str,
text: &str,
width: usize,
style: Style,
) {
let first_w = wrap::display_width(&first.chars().collect::<Vec<_>>());
let cont_w = wrap::display_width(&cont.chars().collect::<Vec<_>>());
let body_w = width.saturating_sub(first_w.max(cont_w)).max(1);
let mut first_row = true;
for src in text.split('\n') {
let chars: Vec<char> = src.chars().collect();
for (s, e) in wrap::wrap_ranges(&chars, body_w) {
let prefix = if first_row { first } else { cont };
first_row = false;
let content: String = chars[s..e].iter().collect();
lines.push(Line::from(vec![
Span::styled(prefix.to_string(), style),
Span::styled(content, style),
]));
}
}
}
const TRAILING_GAP: &str = " ";
fn push_trailing(
lines: &mut Vec<Line<'static>>,
group: Vec<Span<'static>>,
cont: &str,
width: usize,
) {
let span_width =
|spans: &[Span<'_>]| -> usize { spans.iter().map(|s| wrap::str_width(&s.content)).sum() };
let group_w = span_width(&group);
if let Some(last) = lines.last_mut()
&& span_width(&last.spans) + TRAILING_GAP.len() + group_w <= width
{
let style = group.first().map(|s| s.style).unwrap_or_default();
last.spans.push(Span::styled(TRAILING_GAP, style));
last.spans.extend(group);
return;
}
let mut spans = Vec::with_capacity(group.len() + 1);
spans.push(Span::raw(cont.to_string()));
spans.extend(group);
lines.push(Line::from(spans));
}
fn clamp_boundary(text: &str, mut off: usize) -> usize {
while off < text.len() && !text.is_char_boundary(off) {
off += 1;
}
off.min(text.len())
}
fn push_body(
lines: &mut Vec<Line<'static>>,
item: &FeedMessage,
palette: &Palette,
width: usize,
opts: markdown::RenderOpts,
) {
if item.text.is_empty() {
if item.streaming {
lines.push(Line::from("…").dim());
}
return;
}
match item.role {
FeedRole::Note => {
for line in item.text.split('\n') {
lines.push(Line::from(Span::from(line.to_string()).dim()));
}
}
_ => {
let opts = markdown::RenderOpts {
soft_break_as_newline: true,
..opts
};
let rendered = markdown::render_with(&item.text, width, palette, opts);
lines.extend(rendered.lines);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn ru() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn feed_with_tools() -> MessageFeed {
let mut feed = MessageFeed::new();
feed.toggle_tools();
feed
}
fn msg(role: FeedRole, text: &str, thoughts: &str) -> FeedMessage {
FeedMessage {
role,
text: text.to_string(),
thoughts: thoughts.to_string(),
tools: Vec::new(),
streaming: false,
message_ids: vec![Uuid::new_v4()],
model: None,
}
}
#[test]
fn tool_blocks_render() {
let mut feed = feed_with_tools();
let mut m = msg(FeedRole::Assistant, "готово", "");
m.tools.push(FeedToolCall {
name: "note_save".into(),
arguments: "{\"content\":\"x\"}".into(),
result: "Заметка сохранена".into(),
text_offset: m.text.len(),
images: 0,
call_id: None,
running: false,
});
let lines = feed.build_lines(&[m], &Palette::default(), 80, ru());
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(joined.contains("⚒") && joined.contains("note_save"));
assert!(joined.contains("Заметка сохранена"));
}
fn joined(lines: &[Line<'static>]) -> String {
lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect()
}
#[test]
fn tool_card_is_collapsed_by_default() {
let mut feed = MessageFeed::new();
let mut m = msg(FeedRole::Assistant, "готово", "");
m.tools.push(FeedToolCall {
name: "python_exec".into(),
arguments: "{\"code\":\"a = 1\\nprint(a)\"}".into(),
result: "stdout:\n1".into(),
text_offset: m.text.len(),
images: 0,
call_id: None,
running: false,
});
let out = joined(&feed.build_lines(&[m.clone()], &Palette::default(), 80, ru()));
assert!(out.contains("python_exec"), "the header stays: {out}");
assert!(!out.contains("print(a)"), "arguments hidden: {out}");
assert!(!out.contains("stdout"), "result hidden: {out}");
let glyphs = Palette::default().glyphs();
assert!(out.contains(glyphs.collapsed), "collapsed marker: {out}");
assert!(out.contains("детали · "), "label, separator, keycap: {out}");
assert!(out.contains("Ctrl+O"), "{out}");
let card_rows = feed
.build_lines(&[m], &Palette::default(), 80, ru())
.iter()
.filter(|l| joined(std::slice::from_ref(l)).contains("python_exec"))
.count();
assert_eq!(card_rows, 1, "one row per collapsed call");
}
fn long_search_call() -> FeedMessage {
let mut m = msg(FeedRole::Assistant, "", "");
m.tools.push(FeedToolCall {
name: "web_search".into(),
arguments: "{\"query\":\"gpt-oss-120b vs GPT-4o comparison architecture benchmarks multimodal\"}"
.into(),
result: "results".into(),
text_offset: 0,
images: 0,
call_id: None,
running: false,
});
m
}
fn rows(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| joined(std::slice::from_ref(l)))
.collect()
}
#[test]
fn collapsed_pill_moves_whole_to_the_next_row_when_it_does_not_fit() {
let mut feed = MessageFeed::new();
let glyphs = Palette::default().glyphs();
let out = rows(&feed.build_lines(&[long_search_call()], &Palette::default(), 60, ru()));
let pill_row = out
.iter()
.find(|r| r.contains("Ctrl+O"))
.unwrap_or_else(|| panic!("the pill is drawn: {out:#?}"));
assert!(
pill_row.contains(&format!("{} детали · ", glyphs.collapsed)),
"the pill is one piece: {pill_row:?}"
);
assert!(
!pill_row.contains("multimodal"),
"a row of its own: {pill_row:?}"
);
assert!(
pill_row.starts_with(&format!("{RAIL}{}{} ", glyphs.tool_cont, glyphs.collapsed)),
"aligned under the name: {pill_row:?}"
);
let header_row = out
.iter()
.find(|r| r.contains("multimodal"))
.unwrap_or_else(|| panic!("the header's last row: {out:#?}"));
assert!(
!header_row.contains(glyphs.collapsed),
"the header row carries no half of the pill: {header_row:?}"
);
}
#[test]
fn collapsed_pill_stays_on_the_header_row_when_it_fits() {
let mut feed = MessageFeed::new();
let out = rows(&feed.build_lines(&[long_search_call()], &Palette::default(), 80, ru()));
let pill_row = out
.iter()
.find(|r| r.contains("Ctrl+O"))
.unwrap_or_else(|| panic!("the pill is drawn: {out:#?}"));
assert!(
pill_row.contains("multimodal)") && pill_row.contains("детали · "),
"the pill follows the header on its row: {pill_row:?}"
);
}
#[test]
fn images_chip_follows_the_same_rule_as_the_pill() {
let mut feed = MessageFeed::new();
let glyphs = Palette::default().glyphs();
let mut m = long_search_call();
m.tools[0].images = 3;
let out = rows(&feed.build_lines(&[m], &Palette::default(), 60, ru()));
let chip = ru().tf("ui.feed.tool_images", &[("n", "3")]);
let chip_row = out
.iter()
.find(|r| r.contains(&chip))
.unwrap_or_else(|| panic!("the chip is whole on one row: {out:#?}"));
assert!(
!chip_row.contains("multimodal"),
"the chip did not fit the header row: {chip_row:?}"
);
assert!(
chip_row.starts_with(&format!("{RAIL}{}{chip}", glyphs.tool_cont)),
"aligned under the name: {chip_row:?}"
);
assert!(
chip_row.contains("Ctrl+O"),
"the pill fits beside the chip and shares its row: {chip_row:?}"
);
}
#[test]
fn toggle_tools_reveals_arguments_and_result() {
let mut feed = MessageFeed::new();
let mut m = msg(FeedRole::Assistant, "готово", "");
m.tools.push(FeedToolCall {
name: "note_save".into(),
arguments: "{\"content\":\"x\"}".into(),
result: "Заметка сохранена".into(),
text_offset: m.text.len(),
images: 0,
call_id: None,
running: false,
});
feed.toggle_tools();
let out = joined(&feed.build_lines(&[m.clone()], &Palette::default(), 80, ru()));
assert!(out.contains("Заметка сохранена"), "{out}");
assert!(!out.contains("Ctrl+O"), "no pill when expanded: {out}");
feed.toggle_tools();
let out = joined(&feed.build_lines(&[m], &Palette::default(), 80, ru()));
assert!(!out.contains("Заметка сохранена"), "{out}");
}
#[test]
fn expanding_a_card_shows_the_request_in_full() {
let mut feed = MessageFeed::new();
let mut m = msg(FeedRole::Assistant, "", "");
m.tools.push(FeedToolCall {
name: "fetch_url".into(),
arguments: concat!(
r#"{"url":"https://example.org/a/very/long/path/that/goes/on/and/on/well/past/any/header","#,
r#""focus":"memory management modes","headers":["a","b"]}"#
)
.into(),
result: "ok".into(),
text_offset: 0,
images: 0,
call_id: None,
running: false,
});
let collapsed = joined(&feed.build_lines(&[m.clone()], &Palette::default(), 100, ru()));
assert!(collapsed.contains('…'), "collapsed truncates: {collapsed}");
assert!(
!collapsed.contains("management modes"),
"collapsed stays a summary: {collapsed}"
);
feed.toggle_tools();
let rows = row_texts(&feed.build_lines(&[m], &Palette::default(), 100, ru()));
let full = rows.join("");
let head = rows.iter().find(|r| r.contains("fetch_url")).unwrap();
assert!(
!head.contains('(') && !head.contains('…'),
"the name alone: {head:?}"
);
assert!(
full.contains(
"https://example.org/a/very/long/path/that/goes/on/and/on/well/past/any/header"
),
"the whole URL: {full}"
);
assert!(
full.contains(r#"headers: ["a","b"]"#),
"the argument the header could not carry: {full}"
);
let i_result = rows.iter().position(|r| r.contains("└ ok")).unwrap();
let gap = &rows[i_result - 1];
assert!(!is_blank_row(gap), "the gutter continues: {gap:?}");
assert!(
gap.trim_end().ends_with('│'),
"gutter only, nothing else on the row: {gap:?}"
);
assert!(
rows[i_result - 2].contains(": "),
"one gap row, then the arguments: {:?}",
&rows[i_result - 3..=i_result]
);
}
#[test]
fn a_card_with_no_arguments_gets_no_blank_row() {
let mut feed = feed_with_tools();
let mut m = msg(FeedRole::Assistant, "", "");
m.tools.push(FeedToolCall {
name: "current_time".into(),
arguments: "{}".into(),
result: "12:00".into(),
text_offset: 0,
images: 0,
call_id: None,
running: false,
});
let rows = row_texts(&feed.build_lines(&[m], &Palette::default(), 60, ru()));
let i_head = rows
.iter()
.position(|r| r.contains("current_time"))
.unwrap();
assert!(
rows[i_head + 1].contains("12:00"),
"the result follows the header directly: {:?}",
&rows[i_head..]
);
}
#[test]
fn collapsed_card_with_nothing_to_hide_has_no_pill() {
let mut feed = MessageFeed::new();
let mut m = msg(FeedRole::Assistant, "", "");
m.tools.push(FeedToolCall {
name: "current_time".into(),
arguments: String::new(),
result: String::new(),
text_offset: 0,
images: 0,
call_id: None,
running: false,
});
let out = joined(&feed.build_lines(&[m], &Palette::default(), 80, ru()));
assert!(out.contains("current_time"), "the header stays: {out}");
assert!(
!out.contains("Ctrl+O"),
"nothing to reveal — no pill: {out}"
);
}
#[test]
fn collapse_pill_is_localized_for_all_langs() {
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
let mut feed = MessageFeed::new();
let mut m = msg(FeedRole::Assistant, "ok", "");
m.tools.push(FeedToolCall {
name: "note_save".into(),
arguments: "{}".into(),
result: "saved".into(),
text_offset: m.text.len(),
images: 0,
call_id: None,
running: false,
});
let out = joined(&feed.build_lines(&[m], &Palette::default(), 80, loc));
assert!(
out.contains(loc.t("ui.feed.tool_details")),
"{lang:?}: {out}"
);
}
}
#[test]
fn expanded_thoughts_and_exit_code_are_localized() {
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
let mut feed = feed_with_tools();
feed.toggle_thoughts();
let mut m = msg(FeedRole::Assistant, "ok", "reasoning");
m.tools.push(FeedToolCall {
name: "python_exec".into(),
arguments: r#"{"code":"import sys; sys.exit(3)"}"#.into(),
result: format!("stderr:\nboom\n\n{} 3", loc.t("python.console.exit")),
text_offset: m.text.len(),
images: 0,
call_id: None,
running: false,
});
let out = joined(&feed.build_lines(&[m], &Palette::default(), 80, ru()));
assert!(out.contains(ru().t("ui.feed.thoughts")), "{lang:?}: {out}");
assert!(
out.contains(&format!("{} 3", ru().t("ui.feed.exit_code"))),
"{lang:?}: {out}"
);
}
let en = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
let mut feed = feed_with_tools();
feed.toggle_thoughts();
let mut m = msg(FeedRole::Assistant, "ok", "reasoning");
m.tools.push(FeedToolCall {
name: "python_exec".into(),
arguments: r#"{"code":"x"}"#.into(),
result: format!("stdout:\nhi\n\n{} 3", en.t("python.console.exit")),
text_offset: m.text.len(),
images: 0,
call_id: None,
running: false,
});
let out = joined(&feed.build_lines(&[m], &Palette::default(), 80, en));
assert!(
!out.chars().any(|c| ('\u{0400}'..='\u{04FF}').contains(&c)),
"Cyrillic leaked into an en feed: {out}"
);
assert!(
out.contains("exit code: 3") && out.contains("thinking"),
"{out}"
);
}
#[test]
fn role_headers_localized_for_all_langs() {
for &lang in crate::shared::i18n::Lang::ALL {
let loc = crate::shared::i18n::locale(lang);
let mut feed = MessageFeed::new();
let msgs = vec![
msg(FeedRole::User, "hi", ""),
msg(FeedRole::Assistant, "ok", ""),
];
let lines = feed.build_lines(&msgs, &Palette::default(), 80, loc);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
joined.contains(loc.t("ui.feed.role.user"))
&& joined.contains(loc.t("ui.feed.role.assistant")),
"{lang:?}: role headers not from the bundle"
);
}
let en = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
assert_eq!(en.t("ui.feed.role.assistant"), "ASSISTANT");
assert_eq!(en.t("ui.feed.role.user"), "YOU");
}
#[test]
fn custom_role_names_replace_headers_in_caps() {
let mut feed = MessageFeed::new();
feed.set_role_names(CharacterNames {
user: "Гайя".into(),
assistant: "анна".into(),
system: String::new(),
});
let msgs = vec![
msg(FeedRole::User, "привет", ""),
msg(FeedRole::Assistant, "здравствуй", ""),
];
let joined = flatten(&feed.build_lines(&msgs, &Palette::default(), 80, ru()));
assert!(
joined.contains("ГАЙЯ") && joined.contains("АННА"),
"{joined}"
);
assert!(!joined.contains("ВЫ") && !joined.contains("АССИСТЕНТ"));
let mut feed = MessageFeed::new();
feed.set_role_names(CharacterNames {
assistant: "Анна".into(),
..Default::default()
});
let joined = flatten(&feed.build_lines(&msgs, &Palette::default(), 80, ru()));
assert!(joined.contains("ВЫ") && joined.contains("АННА"), "{joined}");
}
#[test]
fn changing_role_names_invalidates_cache() {
let mut feed = MessageFeed::new();
let msgs = vec![msg(FeedRole::User, "привет", "")];
let before = flatten(&feed.build_lines(&msgs, &Palette::default(), 80, ru()));
assert!(before.contains("ВЫ"));
feed.set_role_names(CharacterNames {
user: "Гайя".into(),
..Default::default()
});
let after = flatten(&feed.build_lines(&msgs, &Palette::default(), 80, ru()));
assert!(after.contains("ГАЙЯ"), "{after}");
}
fn linked_spans(lines: &[Line<'static>], palette: &Palette) -> Vec<String> {
lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| {
s.style.fg == Some(palette.accent)
&& s.style.add_modifier.contains(Modifier::UNDERLINED)
})
.map(|s| s.content.to_string())
.collect()
}
fn chat_uuid(n: u128) -> Uuid {
Uuid::from_u128(0x1a2b_3c4d_0000_4000_8000_0000_0000_0000 + n)
}
#[test]
fn a_resolvable_chat_address_becomes_a_link() {
let palette = Palette::default();
let id = chat_uuid(1);
let mut feed = MessageFeed::new();
feed.set_known_chats(vec![id]);
let text = format!("см. {}", crate::features::chat_links::uri(id));
let msgs = vec![msg(FeedRole::Assistant, &text, "")];
let lines = feed.build_lines(&msgs, &palette, 80, ru());
assert_eq!(linked_spans(&lines, &palette), vec!["chat://1a2b3c4d"]);
assert_eq!(feed.chat_links(), vec![id]);
}
#[test]
fn an_unknown_address_stays_plain_text() {
let palette = Palette::default();
let mut feed = MessageFeed::new();
feed.set_known_chats(vec![chat_uuid(1)]);
let msgs = vec![msg(FeedRole::Assistant, "см. chat://deadbeef", "")];
let lines = feed.build_lines(&msgs, &palette, 80, ru());
assert!(linked_spans(&lines, &palette).is_empty());
assert!(feed.chat_links().is_empty());
assert!(flatten(&lines).contains("chat://deadbeef"));
}
#[test]
fn an_address_survives_a_narrow_panel() {
let palette = Palette::default();
let id = chat_uuid(1);
let mut feed = MessageFeed::new();
feed.set_known_chats(vec![id]);
let text = format!(
"подробности лежат в {}",
crate::features::chat_links::uri(id)
);
let msgs = vec![msg(FeedRole::Assistant, &text, "")];
let lines = feed.build_lines(&msgs, &palette, 18, ru());
let linked: String = linked_spans(&lines, &palette).concat();
assert_eq!(linked, "chat://1a2b3c4d", "{:?}", flatten(&lines));
assert_eq!(feed.chat_links(), vec![id]);
}
#[test]
fn the_markdown_link_form_is_recognized() {
let palette = Palette::default();
let id = chat_uuid(1);
let mut feed = MessageFeed::new();
feed.set_known_chats(vec![id]);
let text = format!("[Бюджет]({})", crate::features::chat_links::uri(id));
let msgs = vec![msg(FeedRole::Assistant, &text, "")];
let lines = feed.build_lines(&msgs, &palette, 80, ru());
assert_eq!(linked_spans(&lines, &palette), vec!["chat://1a2b3c4d"]);
assert!(flatten(&lines).contains("Бюджет"));
}
#[test]
fn links_are_deduped_and_newest_block_first() {
let palette = Palette::default();
let (a, b) = (
chat_uuid(1),
Uuid::from_u128(0x9f8e_7d6c_0000_4000_8000_0000_0000_0002),
);
let mut feed = MessageFeed::new();
feed.set_known_chats(vec![a, b]);
let msgs = vec![
msg(
FeedRole::Assistant,
&format!(
"{} и снова {}",
crate::features::chat_links::uri(a),
crate::features::chat_links::uri(a)
),
"",
),
msg(
FeedRole::Assistant,
&crate::features::chat_links::uri(b),
"",
),
];
feed.build_lines(&msgs, &palette, 80, ru());
assert_eq!(feed.chat_links(), vec![b, a]);
}
#[test]
fn changing_the_address_book_invalidates_cache() {
let palette = Palette::default();
let id = chat_uuid(1);
let mut feed = MessageFeed::new();
let text = format!("см. {}", crate::features::chat_links::uri(id));
let msgs = vec![msg(FeedRole::Assistant, &text, "")];
feed.build_lines(&msgs, &palette, 80, ru());
assert!(feed.chat_links().is_empty(), "nothing known yet");
feed.set_known_chats(vec![id]);
feed.build_lines(&msgs, &palette, 80, ru());
assert_eq!(feed.chat_links(), vec![id], "the book must reach the cache");
}
fn render_and_find_link(feed: &mut MessageFeed, msgs: &[FeedMessage], w: u16, h: u16) -> Rect {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| {
feed.render(f, f.area(), "Чат", "", msgs, &Palette::default(), ru());
})
.unwrap();
let hit = feed.link_hits.first().copied().expect("a drawn reference");
Rect::new(hit.start, hit.row, hit.end - hit.start, 1)
}
#[test]
fn a_click_on_the_address_resolves_and_its_neighbours_do_not() {
let id = chat_uuid(1);
let mut feed = MessageFeed::new();
feed.set_known_chats(vec![id]);
let msgs = vec![msg(
FeedRole::Assistant,
&format!("см. {} дальше", crate::features::chat_links::uri(id)),
"",
)];
let at = render_and_find_link(&mut feed, &msgs, 60, 12);
assert_eq!(feed.chat_link_at(at.x, at.y), Some(id), "first cell");
assert_eq!(
feed.chat_link_at(at.x + at.width - 1, at.y),
Some(id),
"last cell"
);
assert_eq!(feed.chat_link_at(at.x + at.width, at.y), None);
assert_eq!(feed.chat_link_at(at.x.saturating_sub(1), at.y), None);
assert_eq!(feed.chat_link_at(at.x, at.y + 1), None, "another row");
}
#[test]
fn the_click_map_is_in_absolute_screen_cells() {
let id = chat_uuid(1);
let mut feed = MessageFeed::new();
feed.set_known_chats(vec![id]);
let uri = crate::features::chat_links::uri(id);
let msgs = vec![msg(FeedRole::Assistant, &uri, "")];
let at = render_and_find_link(&mut feed, &msgs, 60, 12);
assert_eq!(at.x, 3, "border + rail");
assert_eq!(at.width as usize, uri.chars().count());
}
#[test]
fn an_undrawn_feed_has_no_clickable_links() {
let id = chat_uuid(1);
let mut feed = MessageFeed::new();
feed.set_known_chats(vec![id]);
feed.build_lines(
&[msg(
FeedRole::Assistant,
&crate::features::chat_links::uri(id),
"",
)],
&Palette::default(),
80,
ru(),
);
assert_eq!(feed.chat_link_at(3, 1), None);
}
#[test]
fn the_map_follows_the_scroll() {
let id = chat_uuid(1);
let mut feed = MessageFeed::new();
feed.set_known_chats(vec![id]);
let mut msgs = vec![msg(
FeedRole::Assistant,
&crate::features::chat_links::uri(id),
"",
)];
msgs.extend((0..30).map(|i| msg(FeedRole::User, &format!("реплика-{i}"), "")));
let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 10)).unwrap();
term.draw(|f| {
feed.render(f, f.area(), "Чат", "", &msgs, &Palette::default(), ru());
})
.unwrap();
assert!(
feed.link_hits.is_empty(),
"a reference off-screen is not clickable"
);
feed.scroll_up(usize::MAX);
term.draw(|f| {
feed.render(f, f.area(), "Чат", "", &msgs, &Palette::default(), ru());
})
.unwrap();
let hit = feed.link_hits.first().copied().expect("scrolled into view");
assert_eq!(feed.chat_link_at(hit.start, hit.row), Some(id));
}
fn flatten(lines: &[Line<'static>]) -> String {
lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect()
}
#[test]
fn compat_palette_renders_without_emoji() {
let mut feed = MessageFeed::new();
let mut m = msg(FeedRole::Assistant, "готово", "думал");
m.tools.push(FeedToolCall {
name: "note_save".into(),
arguments: "{}".into(),
result: "ок".into(),
text_offset: m.text.len(),
images: 0,
call_id: None,
running: false,
});
let user = msg(FeedRole::User, "привет", "");
let compat = Palette::default().with_compat(true);
let lines = feed.build_lines(&[user, m], &compat, 80, ru());
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(joined.contains("* АССИСТЕНТ") && joined.contains("> ВЫ"));
assert!(
joined.contains("# note_save"),
"ASCII tool prefix: {joined}"
);
assert!(joined.contains("► мысли"), "compat thoughts pill: {joined}");
for banned in ['✦', '❯', '⚒', '▸'] {
assert!(!joined.contains(banned), "{banned} left behind: {joined}");
}
}
#[test]
fn python_tool_renders_highlighted_code_and_console() {
let mut feed = feed_with_tools();
let mut m = msg(FeedRole::Assistant, "готово", "");
m.tools.push(FeedToolCall {
name: "python_exec".into(),
arguments: r#"{"code":"print(42)"}"#.into(),
result: "stdout:\n42".into(),
text_offset: m.text.len(),
images: 0,
call_id: None,
running: false,
});
let palette = Palette::for_theme(crate::shared::config::Theme::Dark);
let lines = feed.build_lines(&[m], &palette, 80, ru());
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(joined.contains("python_exec"), "{joined}");
assert!(!joined.contains("{\"code\""), "raw JSON must not be shown");
assert!(joined.contains("print(42)"), "code: {joined}");
assert!(
joined.contains("stdout") && joined.contains("42"),
"console: {joined}"
);
let has_rgb = lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| s.content.contains("print") && matches!(s.style.fg, Some(Color::Rgb(..))));
assert!(has_rgb, "expected a highlighted (RGB) code span");
}
#[test]
fn python_stderr_uses_error_color() {
let mut feed = feed_with_tools();
let palette = Palette::default();
let mut m = msg(FeedRole::Assistant, "", "");
m.tools.push(FeedToolCall {
name: "python_exec".into(),
arguments: r#"{"code":"raise SystemExit(1)"}"#.into(),
result: "stderr:\nTraceback here\n\nкод возврата: 1".into(),
text_offset: 0,
images: 0,
call_id: None,
running: false,
});
let lines = feed.build_lines(&[m], &palette, 80, ru());
let err_line = lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains("Traceback here")));
let err_line = err_line.expect("stderr line");
assert!(
err_line
.spans
.iter()
.any(|s| s.content.contains("Traceback") && s.style.fg == Some(palette.error)),
"stderr must be the error color"
);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(joined.contains("код возврата: 1"), "{joined}");
}
#[test]
fn tool_call_renders_after_preceding_text_inline() {
let mut feed = MessageFeed::new();
let mut m = msg(FeedRole::Assistant, "Ищу погоду.\n\nГотово: ясно.", "");
let off = "Ищу погоду.".len();
m.tools.push(FeedToolCall {
name: "web_search".into(),
arguments: "{}".into(),
result: "ясно".into(),
text_offset: off,
images: 0,
call_id: None,
running: false,
});
let lines = feed.build_lines(&[m], &Palette::default(), 80, ru());
let rows: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let idx_before = rows.iter().position(|r| r.contains("Ищу погоду")).unwrap();
let idx_tool = rows.iter().position(|r| r.contains("web_search")).unwrap();
let idx_after = rows.iter().position(|r| r.contains("Готово")).unwrap();
assert!(
idx_before < idx_tool && idx_tool < idx_after,
"expected order: text-before < call < text-after, got {idx_before}/{idx_tool}/{idx_after}"
);
}
fn row_texts(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
fn is_blank_row(r: &str) -> bool {
r.trim_matches(|c| c == '▌' || c == ' ').is_empty()
}
#[test]
fn tool_block_separated_from_text_by_blank_lines() {
let mut feed = MessageFeed::new();
let mut m = msg(FeedRole::Assistant, "доПОСЛЕ", "");
m.tools.push(FeedToolCall {
name: "note_save".into(),
arguments: String::new(),
result: String::new(),
text_offset: "до".len(),
images: 0,
call_id: None,
running: false,
});
let rows = row_texts(&feed.build_lines(&[m], &Palette::default(), 80, ru()));
let i_before = rows.iter().position(|r| r.contains("до")).unwrap();
let i_tool = rows.iter().position(|r| r.contains("note_save")).unwrap();
let i_after = rows.iter().position(|r| r.contains("ПОСЛЕ")).unwrap();
assert!(rows[i_before + 1..i_tool].iter().all(|r| is_blank_row(r)));
assert_eq!(
i_tool - i_before,
2,
"expected one blank line before the call"
);
assert_eq!(
i_after - i_tool,
2,
"expected one blank line after the call"
);
}
#[test]
fn tool_last_in_message_keeps_railed_trailing_blank() {
let mut feed = feed_with_tools();
let mut m = msg(FeedRole::Assistant, "Считаю.", "");
m.tools.push(FeedToolCall {
name: "python_exec".into(),
arguments: r#"{"code":"print(1)"}"#.into(),
result: "stdout:\n1".into(),
text_offset: "Считаю.".len(),
images: 0,
call_id: None,
running: false,
});
let next = msg(FeedRole::User, "дальше", "");
let rows = row_texts(&feed.build_lines(&[m, next], &Palette::default(), 60, ru()));
let i_result = rows.iter().rposition(|r| r.contains('1')).unwrap();
let i_next = rows.iter().position(|r| r.contains("дальше")).unwrap();
let between: Vec<&String> = rows[i_result + 1..i_next].iter().collect();
let blanks = between.iter().filter(|r| is_blank_row(r)).count();
assert_eq!(blanks, 1, "expected one blank line: {between:?}");
assert!(
rows[i_next - 1].contains('▌'),
"the gap after the card should carry a rail: {:?}",
rows[i_next - 1]
);
}
#[test]
fn consecutive_tool_blocks_have_single_blank_between() {
let mut feed = MessageFeed::new();
let mut m = msg(FeedRole::Assistant, "", "");
for name in ["first_tool", "second_tool"] {
m.tools.push(FeedToolCall {
name: name.into(),
arguments: String::new(),
result: String::new(),
text_offset: 0,
images: 0,
call_id: None,
running: false,
});
}
let rows = row_texts(&feed.build_lines(&[m], &Palette::default(), 80, ru()));
let i1 = rows.iter().position(|r| r.contains("first_tool")).unwrap();
let i2 = rows.iter().position(|r| r.contains("second_tool")).unwrap();
assert_eq!(i2 - i1, 2, "expected one blank line between adjacent calls");
assert!(rows[i1 + 1..i2].iter().all(|r| is_blank_row(r)));
}
#[test]
fn long_tool_result_wraps_not_truncated() {
let mut feed = feed_with_tools();
let mut m = msg(FeedRole::Assistant, "ок", "");
let long = "слово ".repeat(40); m.tools.push(FeedToolCall {
name: "web_search".into(),
arguments: String::new(),
result: long.trim_end().into(),
text_offset: 0,
images: 0,
call_id: None,
running: false,
});
let lines = feed.build_lines(&[m], &Palette::default(), 30, ru());
let gutter_rows = lines
.iter()
.filter(|l| {
let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
s.contains("слово")
})
.count();
assert!(
gutter_rows > 1,
"a long result should wrap, rows: {gutter_rows}"
);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
!joined.contains('…'),
"the result must not be truncated with an ellipsis"
);
}
#[test]
fn followup_starts_new_bubble_and_hides_control_tool() {
use crate::entities::message::{Message, MessageRole, ToolCallRecord};
let mut a1 = Message::assistant("Первое сообщение.");
a1.tool_calls = vec![ToolCallRecord {
thought_signature: None,
images: 0,
id: "c1".into(),
name: "send_followup_message".into(),
arguments: serde_json::json!({}),
result: Some("ок".into()),
subagent: None,
}];
let tool_msg = {
let mut m = Message::new(MessageRole::Tool, "ок");
m.tool_call_id = Some("c1".into());
m.tool_name = Some("send_followup_message".into());
m
};
let mut a2 = Message::assistant("Второе сообщение.");
a2.new_bubble = true;
let feed = FeedMessage::from_messages(&[a1, tool_msg, a2]);
assert_eq!(feed.len(), 2);
assert_eq!(feed[0].text, "Первое сообщение.");
assert_eq!(feed[1].text, "Второе сообщение.");
assert!(
feed[0].tools.is_empty(),
"a control call should not produce a tool block"
);
}
#[test]
fn assistant_rounds_without_new_bubble_still_merge() {
use crate::entities::message::{Message, MessageRole, ToolCallRecord};
let mut a1 = Message::assistant("Ищу.");
a1.tool_calls = vec![ToolCallRecord {
thought_signature: None,
images: 0,
id: "c1".into(),
name: "note_save".into(),
arguments: serde_json::json!({}),
result: Some("ok".into()),
subagent: None,
}];
let tool_msg = {
let mut m = Message::new(MessageRole::Tool, "ok");
m.tool_call_id = Some("c1".into());
m
};
let a2 = Message::assistant("Готово.");
let feed = FeedMessage::from_messages(&[a1, tool_msg, a2]);
assert_eq!(feed.len(), 1, "regular rounds merge");
assert_eq!(feed[0].tools.len(), 1, "a regular tool block is visible");
}
#[test]
fn bubble_count_agrees_with_the_list_counter() {
use crate::entities::chat::visible_message_count;
use crate::entities::message::{Message, MessageRole};
let mut followup = Message::assistant("Ещё одно.");
followup.new_bubble = true;
let messages = [
Message::assistant("Приветствие."),
Message::user("вопрос"),
Message::assistant(""),
Message::new(MessageRole::Tool, "результат"),
Message::assistant("Ответ."),
followup,
Message::new(MessageRole::System, "инструкция"),
Message::user("ещё вопрос"),
];
for upto in 0..=messages.len() {
assert_eq!(
FeedMessage::from_messages(&messages[..upto]).len(),
visible_message_count(&messages[..upto]),
"the list and the feed disagree at prefix {upto}"
);
}
}
#[test]
fn from_message_projects_system_as_a_note() {
let sys = Message::new(MessageRole::System, "Director: wrap up");
let note = FeedMessage::from_message(&sys).unwrap();
assert_eq!(note.role, FeedRole::Note);
assert_eq!(note.text, "Director: wrap up");
assert!(FeedMessage::from_message(&Message::new(MessageRole::Tool, "r")).is_none());
let user = Message::user("hi");
assert_eq!(
FeedMessage::from_message(&user).unwrap().role,
FeedRole::User
);
}
#[test]
fn collapsed_thoughts_show_indicator_not_content() {
let mut feed = MessageFeed::new(); let lines = feed.build_lines(
&[msg(FeedRole::Assistant, "ответ", "секрет\nмысль")],
&Palette::default(),
80,
ru(),
);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(joined.contains("▸ мысли"));
assert!(!joined.contains("секрет"));
}
#[test]
fn expanded_thoughts_show_content() {
let mut feed = MessageFeed::new();
feed.toggle_thoughts();
let lines = feed.build_lines(
&[msg(FeedRole::Assistant, "ответ", "секрет")],
&Palette::default(),
80,
ru(),
);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(joined.contains("секрет"));
}
#[test]
fn user_message_preserves_single_newlines() {
let mut feed = MessageFeed::new();
let lines = feed.build_lines(
&[msg(FeedRole::User, "Привет!\nКак дела?", "")],
&Palette::default(),
80,
ru(),
);
let rows = row_texts(&lines);
let i_first = rows.iter().position(|r| r.contains("Привет!")).unwrap();
let i_second = rows.iter().position(|r| r.contains("Как дела?")).unwrap();
assert_ne!(
i_first, i_second,
"the two lines should end up on different visual rows"
);
assert!(
!rows
.iter()
.any(|r| r.contains("Привет!") && r.contains("Как дела?")),
"lines must not merge into a single row"
);
}
#[test]
fn markdown_is_applied_to_body() {
let mut feed = MessageFeed::new();
let lines = feed.build_lines(
&[msg(FeedRole::Assistant, "формула $x^2$", "")],
&Palette::default(),
80,
ru(),
);
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(joined.contains("x²"));
}
#[test]
fn rail_is_not_dimmed_next_to_table_borders() {
let mut feed = MessageFeed::new();
let table = "| a | b |\n|---|---|\n| 1 | 2 |";
let lines = feed.build_lines(
&[msg(FeedRole::Assistant, table, "")],
&Palette::default(),
80,
ru(),
);
for line in &lines {
if let Some(rail) = line.spans.first()
&& rail.content.starts_with('▌')
{
assert!(
!rail.style.add_modifier.contains(Modifier::DIM),
"the rail must not inherit DIM from a table/divider line"
);
}
}
}
#[test]
fn table_row_separators_follow_setting_and_invalidate_cache() {
let mut feed = MessageFeed::new();
let palette = Palette::default();
let table = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |";
let mids = |lines: &[Line<'static>]| {
lines
.iter()
.filter(|l| l.spans.iter().any(|s| s.content.contains('├')))
.count()
};
let off = feed.build_lines(&[msg(FeedRole::Assistant, table, "")], &palette, 80, ru());
assert_eq!(mids(&off), 1, "by default: only under the header");
feed.set_table_row_separators(true);
let on = feed.build_lines(&[msg(FeedRole::Assistant, table, "")], &palette, 80, ru());
assert_eq!(
mids(&on),
2,
"enabled: header separator + one row separator (cache reset by key)"
);
}
#[test]
fn render_mermaid_follows_setting_and_invalidates_cache() {
let mut feed = MessageFeed::new();
let palette = Palette::default();
let md = "```mermaid\nflowchart LR\n A[Start] --> B[End]\n```";
let joined = |lines: &[Line<'static>]| -> String {
lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect()
};
let on = feed.build_lines(&[msg(FeedRole::Assistant, md, "")], &palette, 90, ru());
assert!(
joined(&on).contains('┌') && !joined(&on).contains("```"),
"on by default — a diagram, not the source"
);
feed.set_render_mermaid(false);
let off = feed.build_lines(&[msg(FeedRole::Assistant, md, "")], &palette, 90, ru());
assert!(
joined(&off).contains("```mermaid"),
"disabled — source (cache reset by key)"
);
}
#[test]
fn model_name_follows_setting_and_invalidates_cache() {
let mut feed = MessageFeed::new();
let palette = Palette::default();
let mut item = msg(FeedRole::Assistant, "ответ", "");
item.model = Some("gemma-4-31b".into());
let joined = |lines: &[Line<'static>]| -> String {
lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect()
};
let off = feed.build_lines(std::slice::from_ref(&item), &palette, 80, ru());
assert!(
!joined(&off).contains("gemma-4-31b"),
"off by default — the header names only the role"
);
feed.set_show_model_name(true);
let on = feed.build_lines(std::slice::from_ref(&item), &palette, 80, ru());
let header = on
.iter()
.find(|l| {
l.spans
.iter()
.any(|s| s.content.contains(ru().t("ui.feed.role.assistant")))
})
.expect("the assistant header row");
let text: String = header.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
text.contains("gemma-4-31b"),
"enabled — the name sits on the header row itself (cache reset by key)"
);
let name = header
.spans
.iter()
.find(|s| s.content.contains("gemma-4-31b"))
.unwrap();
assert_eq!(name.style.fg, Some(palette.muted));
assert!(!name.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn a_message_without_a_model_name_is_unchanged_by_the_setting() {
let palette = Palette::default();
let item = msg(FeedRole::Assistant, "ответ", "");
let mut off = MessageFeed::new();
let mut on = MessageFeed::new();
on.set_show_model_name(true);
let rows = |feed: &mut MessageFeed| -> Vec<String> {
row_texts(&feed.build_lines(std::slice::from_ref(&item), &palette, 80, ru()))
};
assert_eq!(rows(&mut off), rows(&mut on));
}
#[test]
fn the_model_name_is_read_from_metadata_and_survives_round_stitching() {
use crate::entities::message::{MessageMetadata, ToolCallRecord};
let meta = |model: &str| MessageMetadata {
sampling: Default::default(),
mode: Default::default(),
model: Some(model.to_string()),
finish: None,
};
let mut r1 = Message::assistant("ищу");
r1.metadata = Some(meta("gemma-4-31b"));
r1.tool_calls = vec![ToolCallRecord {
id: "c1".into(),
name: "web_search".into(),
arguments: serde_json::json!({}),
result: Some("ок".into()),
thought_signature: None,
images: 0,
subagent: None,
}];
let tool = {
let mut m = Message::new(MessageRole::Tool, "ок");
m.tool_call_id = Some("c1".into());
m
};
let mut r2 = Message::assistant("нашёл");
r2.metadata = Some(meta("gemma-4-31b"));
let feed = FeedMessage::from_messages(&[r1, tool, r2]);
assert_eq!(feed.len(), 1, "the rounds stitch into one bubble");
assert_eq!(feed[0].model.as_deref(), Some("gemma-4-31b"));
let plain = FeedMessage::from_messages(&[
Message::user("привет"),
Message::assistant("здравствуйте"),
]);
assert!(plain.iter().all(|m| m.model.is_none()));
}
#[test]
fn streamed_mermaid_stays_source_until_fence_closes() {
let mut feed = MessageFeed::new();
let palette = Palette::default();
let joined = |lines: &[Line<'static>]| -> String {
lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect()
};
let partial = "```mermaid\nflowchart LR\n A[Start] --> B[End]";
let mid = feed.build_lines(&[msg(FeedRole::Assistant, partial, "")], &palette, 90, ru());
assert!(
joined(&mid).contains("```mermaid") && !joined(&mid).contains('┌'),
"during streaming — source, not a diagram: {}",
joined(&mid)
);
let full = "```mermaid\nflowchart LR\n A[Start] --> B[End]\n```";
let done = feed.build_lines(&[msg(FeedRole::Assistant, full, "")], &palette, 90, ru());
assert!(
joined(&done).contains('┌') && !joined(&done).contains("```"),
"once the fence completes — a diagram: {}",
joined(&done)
);
}
#[test]
fn an_empty_feed_without_an_engine_explains_how_to_start() {
let mut feed = MessageFeed::new();
let routes = [
"Ctrl+P",
"/settings",
"mindfork llama setup",
"mindfork demo",
];
let text = visible(&mut feed, &[], 240, 12).join("\n");
assert!(text.contains(ru().t("ui.feed.empty")), "{text}");
assert!(routes.iter().all(|r| !text.contains(r)), "{text}");
feed.set_engine_missing(true);
let text = visible(&mut feed, &[], 240, 12).join("\n");
assert!(!text.contains(ru().t("ui.feed.empty")), "{text}");
for route in routes {
assert!(text.contains(route), "{route} missing:\n{text}");
}
feed.set_engine_missing(false);
let text = visible(&mut feed, &[], 240, 12).join("\n");
assert!(text.contains(ru().t("ui.feed.empty")), "{text}");
}
#[test]
fn a_chat_with_messages_shows_no_setup_routes() {
let mut feed = MessageFeed::new();
feed.set_engine_missing(true);
let text = visible(&mut feed, &numbered(2), 240, 12).join("\n");
assert!(text.contains("сообщение-1"), "{text}");
assert!(!text.contains("mindfork demo"), "{text}");
}
fn visible(feed: &mut MessageFeed, messages: &[FeedMessage], w: u16, h: u16) -> Vec<String> {
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| feed.render(f, f.area(), "Чат", "", messages, &Palette::default(), ru()))
.unwrap();
let buf = term.backend().buffer().clone();
let area = buf.area;
(area.top()..area.bottom())
.map(|y| {
(area.left()..area.right())
.map(|x| buf[(x, y)].symbol())
.collect()
})
.collect()
}
fn numbered(n: usize) -> Vec<FeedMessage> {
(0..n)
.map(|i| msg(FeedRole::User, &format!("сообщение-{i}"), ""))
.collect()
}
fn numbered_long(n: usize) -> Vec<FeedMessage> {
(0..n)
.map(|i| {
msg(
FeedRole::User,
&format!("сообщение-{i} с довольно длинным текстом который переносится"),
"",
)
})
.collect()
}
#[test]
fn from_messages_records_ids_including_every_merged_round() {
use crate::entities::message::{Message, MessageRole, ToolCallRecord};
let user = Message::user("вопрос");
let mut r1 = Message::assistant("Ищу.");
r1.tool_calls = vec![ToolCallRecord {
thought_signature: None,
images: 0,
id: "c1".into(),
name: "note_save".into(),
arguments: serde_json::json!({}),
result: Some("ok".into()),
subagent: None,
}];
let tool = {
let mut m = Message::new(MessageRole::Tool, "ok");
m.tool_call_id = Some("c1".into());
m
};
let r2 = Message::assistant("Готово.");
let (uid, id1, id2) = (user.id, r1.id, r2.id);
let feed = FeedMessage::from_messages(&[user, r1, tool, r2]);
assert_eq!(feed.len(), 2, "the two rounds merge into one bubble");
assert_eq!(feed[0].message_ids, vec![uid]);
assert_eq!(
feed[1].message_ids,
vec![id1, id2],
"a merged bubble must carry every round's id, or a jump to the \
second round would find nothing"
);
}
#[test]
fn focus_scrolls_the_message_into_view() {
let messages = numbered(12);
let mut feed = MessageFeed::new();
assert!(feed.focus_message(&messages, messages[9].message_ids[0], None));
let rows = visible(&mut feed, &messages, 40, 10);
assert!(
rows.iter().any(|r| r.contains("сообщение-9")),
"the focused message must be visible: {rows:?}"
);
assert!(feed.scroll_row() > 0, "a later message moves the scroll");
assert!(!feed.is_following(), "a jump turns off tail-following");
let mut feed = MessageFeed::new();
assert!(feed.focus_message(&messages, messages[0].message_ids[0], None));
let rows = visible(&mut feed, &messages, 40, 10);
assert!(rows.iter().any(|r| r.contains("сообщение-0")), "{rows:?}");
assert_eq!(feed.scroll_row(), 0);
}
#[test]
fn search_highlights_every_match_and_counts_them() {
let palette = Palette::default();
let messages = vec![
msg(FeedRole::User, "первое про маркер", ""),
msg(FeedRole::Assistant, "второе про маркер и снова маркер", ""),
];
let mut feed = MessageFeed::new();
feed.set_search(Some("маркер"));
let lines = feed.build_lines(&messages, &palette, 60, ru());
let hits = accented(&lines, &palette);
assert_eq!(hits, vec!["маркер"; 3], "every occurrence must light up");
assert_eq!(
feed.match_position(),
Some((1, hits.len())),
"the counter must equal the highlights, and start on the first"
);
}
#[test]
fn next_match_moves_the_viewport_inside_one_long_message() {
let body = format!("маркер{}маркер", "заполнение ".repeat(400));
let messages = vec![msg(FeedRole::Assistant, &body, "")];
let mut feed = MessageFeed::new();
feed.set_search(Some("маркер"));
let _ = visible(&mut feed, &messages, 40, 10);
assert_eq!(feed.match_position(), Some((1, 2)));
let first = feed.scroll_row();
feed.next_match();
let _ = visible(&mut feed, &messages, 40, 10);
assert_eq!(feed.match_position(), Some((2, 2)));
assert!(
feed.scroll_row() > first,
"next must move the view inside one long message: {} -> {}",
first,
feed.scroll_row()
);
}
#[test]
fn next_and_prev_wrap_around() {
let palette = Palette::default();
let messages = vec![msg(FeedRole::User, "маркер раз маркер два", "")];
let mut feed = MessageFeed::new();
feed.set_search(Some("маркер"));
let _ = feed.build_lines(&messages, &palette, 60, ru());
assert_eq!(feed.match_position(), Some((1, 2)));
feed.next_match();
assert_eq!(feed.match_position(), Some((2, 2)));
feed.next_match();
assert_eq!(feed.match_position(), Some((1, 2)), "wraps past the end");
feed.prev_match();
assert_eq!(feed.match_position(), Some((2, 2)), "wraps past the start");
}
#[test]
fn navigating_without_matches_is_a_no_op() {
let palette = Palette::default();
let messages = vec![msg(FeedRole::User, "ничего похожего", "")];
let mut feed = MessageFeed::new();
feed.set_search(Some("маркер"));
let _ = feed.build_lines(&messages, &palette, 60, ru());
assert_eq!(feed.match_position(), None);
feed.next_match();
feed.prev_match();
assert_eq!(feed.match_position(), None);
}
#[test]
fn clear_search_drops_the_highlight_but_keeps_the_marker() {
let palette = Palette::default();
let messages = vec![msg(FeedRole::User, "про маркер", "")];
let mut feed = MessageFeed::new();
assert!(feed.focus_message(&messages, messages[0].message_ids[0], None));
feed.set_search(Some("маркер"));
let _ = feed.build_lines(&messages, &palette, 60, ru());
assert!(feed.match_position().is_some());
feed.clear_search();
let lines = feed.build_lines(&messages, &palette, 60, ru());
assert!(accented(&lines, &palette).is_empty(), "highlight gone");
assert_eq!(feed.match_position(), None, "match list gone");
assert_eq!(feed.marker(), Some(0), "the jump's mark stays");
}
#[test]
fn typing_a_search_query_does_not_invalidate_the_cache() {
let palette = Palette::default();
let messages = vec![msg(FeedRole::User, "маркер и метка рядом", "")];
let mut feed = MessageFeed::new();
let _ = feed.build_lines(&messages, &palette, 60, ru());
feed.cache_reset = false;
for q in ["мар", "марк", "маркер"] {
feed.set_search(Some(q));
let _ = feed.build_lines(&messages, &palette, 60, ru());
assert!(!feed.cache_reset, "query {q:?} must not rebuild a block");
}
}
#[test]
fn focus_survives_a_resize() {
let messages = numbered_long(12);
let mut feed = MessageFeed::new();
assert!(feed.focus_message(&messages, messages[8].message_ids[0], None));
let wide = visible(&mut feed, &messages, 60, 10);
assert!(wide.iter().any(|r| r.contains("сообщение-8")), "{wide:?}");
let row_wide = feed.scroll_row();
let narrow = visible(&mut feed, &messages, 30, 10);
assert!(
narrow.iter().any(|r| r.contains("сообщение-8")),
"after a resize the anchored message must still be in view: {narrow:?}"
);
assert_ne!(
row_wide,
feed.scroll_row(),
"the rewrap should have moved the row — otherwise the test proves nothing"
);
}
#[test]
fn manual_scroll_releases_the_anchor_but_keeps_the_marker() {
let messages = numbered(12);
for scroll in [
(|f: &mut MessageFeed| f.scroll_up(2)) as fn(&mut MessageFeed),
|f: &mut MessageFeed| f.scroll_down(2),
] {
let mut feed = MessageFeed::new();
feed.focus_message(&messages, messages[9].message_ids[0], None);
let _ = visible(&mut feed, &messages, 40, 10);
assert_eq!(feed.anchor(), Some(9));
assert_eq!(feed.marker(), Some(9));
scroll(&mut feed);
assert_eq!(feed.anchor(), None, "a manual scroll releases the anchor");
assert_eq!(feed.marker(), Some(9), "but the marker stays put");
let _ = visible(&mut feed, &messages, 40, 10);
assert_eq!(feed.marker(), Some(9));
}
}
#[test]
fn resize_after_a_manual_scroll_does_not_jump_back() {
let messages = numbered_long(12);
let mut feed = MessageFeed::new();
feed.focus_message(&messages, messages[8].message_ids[0], None);
let _ = visible(&mut feed, &messages, 60, 10);
feed.scroll_up(1000);
let before = visible(&mut feed, &messages, 60, 10);
assert!(
before.iter().any(|r| r.contains("сообщение-0")),
"precondition: at the top of the feed: {before:?}"
);
let after = visible(&mut feed, &messages, 30, 10);
assert!(
after.iter().any(|r| r.contains("сообщение-0")),
"a resize must not yank the view back to the jump target: {after:?}"
);
assert_eq!(feed.scroll_row(), 0);
assert_eq!(feed.marker(), Some(8), "the marker still survives");
}
#[test]
fn marked_message_rail_uses_accent() {
let palette = Palette::default();
let messages = numbered(4);
let mut feed = MessageFeed::new();
feed.focus_message(&messages, messages[2].message_ids[0], None);
let lines = feed.build_lines(&messages, &palette, 40, ru());
let rail_color = |needle: &str| -> Option<Color> {
lines
.iter()
.find(|l| l.spans.iter().any(|s| s.content.contains(needle)))
.and_then(|l| l.spans.first())
.and_then(|s| s.style.fg)
};
assert_eq!(rail_color("сообщение-2"), Some(palette.accent));
assert_eq!(rail_color("сообщение-1"), Some(palette.user));
assert_ne!(palette.accent, palette.user, "the marker must be visible");
}
#[test]
fn scrolling_after_a_jump_does_not_invalidate_the_cache() {
let palette = Palette::default();
let messages = numbered(12);
let mut feed = MessageFeed::new();
feed.focus_message(&messages, messages[9].message_ids[0], None);
let _ = feed.build_lines(&messages, &palette, 40, ru());
feed.cache_reset = false;
let _ = feed.build_lines(&messages, &palette, 40, ru());
assert!(!feed.cache_reset, "precondition: the cache is warm");
feed.scroll_up(3);
let _ = feed.build_lines(&messages, &palette, 40, ru());
assert!(
!feed.cache_reset,
"releasing the anchor must not wipe the block cache"
);
feed.focus_message(&messages, messages[2].message_ids[0], None);
let _ = feed.build_lines(&messages, &palette, 40, ru());
assert!(feed.cache_reset, "a new marker changes the rendered rail");
}
#[test]
fn focus_on_unknown_id_is_a_no_op() {
let messages = numbered(12);
let mut feed = MessageFeed::new();
assert!(!feed.focus_message(&messages, Uuid::new_v4(), None));
assert_eq!(feed.anchor(), None);
assert_eq!(feed.marker(), None);
let _ = visible(&mut feed, &messages, 40, 10);
assert!(feed.is_following(), "the feed stays on the tail");
assert!(!feed.focus_message(&[], Uuid::new_v4(), None));
}
#[test]
fn clear_focus_drops_anchor_marker_and_highlight() {
let messages = numbered(6);
let mut feed = MessageFeed::new();
feed.focus_message(&messages, messages[3].message_ids[0], Some("сообщение"));
assert!(feed.highlight.is_some());
feed.clear_focus();
assert_eq!(feed.anchor(), None);
assert_eq!(feed.marker(), None);
assert_eq!(feed.highlight, None);
}
fn accented(lines: &[Line<'static>], palette: &Palette) -> Vec<String> {
lines
.iter()
.flat_map(|l| l.spans.iter().skip(1))
.filter(|s| s.style.fg == Some(palette.accent))
.map(|s| s.content.to_string())
.collect()
}
#[test]
fn highlight_marks_the_query_inside_the_marked_message_only() {
let palette = Palette::default();
let messages = vec![
msg(FeedRole::User, "первое упоминание маркера", ""),
msg(FeedRole::User, "второе упоминание маркера", ""),
];
let mut feed = MessageFeed::new();
assert!(feed.focus_message(&messages, messages[1].message_ids[0], Some("маркер")));
let lines = feed.build_lines(&messages, &palette, 60, ru());
assert_eq!(
accented(&lines, &palette),
vec!["маркер"],
"the marked message, and only it, must show where the query matched"
);
}
#[test]
fn no_query_highlights_nothing() {
let palette = Palette::default();
let messages = vec![msg(FeedRole::User, "упоминание маркера", "")];
for query in [None, Some("")] {
let mut feed = MessageFeed::new();
feed.focus_message(&messages, messages[0].message_ids[0], query);
let lines = feed.build_lines(&messages, &palette, 60, ru());
assert!(
accented(&lines, &palette).is_empty(),
"query {query:?} must not highlight"
);
}
}
#[test]
fn highlight_spans_a_style_boundary_and_keeps_the_markdown_style() {
let palette = Palette::default();
let messages = vec![msg(FeedRole::User, "мар**кер** дальше", "")];
let mut feed = MessageFeed::new();
feed.focus_message(&messages, messages[0].message_ids[0], Some("маркер"));
let lines = feed.build_lines(&messages, &palette, 60, ru());
let hits = accented(&lines, &palette);
assert_eq!(
hits.concat(),
"маркер",
"both halves of a straddling match must be highlighted: {hits:?}"
);
let bold = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content == "кер")
.expect("the emphasized half");
assert!(
bold.style.add_modifier.contains(Modifier::BOLD),
"the emphasis must survive the highlight: {:?}",
bold.style
);
assert_eq!(bold.style.fg, Some(palette.accent));
}
#[test]
fn highlight_survives_wrapping_onto_a_later_row() {
let palette = Palette::default();
let body = format!("{} маркер в самом хвосте", "слово ".repeat(20));
let messages = vec![msg(FeedRole::User, &body, "")];
let mut feed = MessageFeed::new();
feed.focus_message(&messages, messages[0].message_ids[0], Some("маркер"));
let lines = feed.build_lines(&messages, &palette, 40, ru());
let lines = &lines;
let row = lines
.iter()
.position(|l| {
l.spans
.iter()
.skip(1)
.any(|s| s.style.fg == Some(palette.accent))
})
.expect("the match must still be highlighted after wrapping");
assert!(
row > 1,
"precondition: the match has to land past the first wrapped row, \
otherwise the test proves nothing (row {row} of {})",
lines.len()
);
assert_eq!(accented(lines, &palette), vec!["маркер"]);
}
#[test]
fn the_role_header_is_never_highlighted() {
let palette = Palette::default();
let messages = vec![msg(FeedRole::Assistant, "речь про ассистента", "")];
let mut feed = MessageFeed::new();
feed.focus_message(&messages, messages[0].message_ids[0], Some("ассистент"));
let lines = feed.build_lines(&messages, &palette, 60, ru());
let header = &lines[0];
assert!(
header.spans.iter().any(|s| s.content.contains("АССИСТЕНТ")),
"precondition: the header spells the role out: {header:?}"
);
assert_eq!(
accented(&lines, &palette),
vec!["ассистент"],
"only the body matches, never the header"
);
}
#[test]
fn changing_the_query_does_not_invalidate_the_block_cache() {
let palette = Palette::default();
let messages = vec![msg(FeedRole::User, "маркер и метка рядом", "")];
let mut feed = MessageFeed::new();
feed.focus_message(&messages, messages[0].message_ids[0], Some("маркер"));
let _ = feed.build_lines(&messages, &palette, 60, ru());
feed.cache_reset = false;
let _ = feed.build_lines(&messages, &palette, 60, ru());
assert!(!feed.cache_reset, "precondition: the cache is warm");
feed.focus_message(&messages, messages[0].message_ids[0], Some("метка"));
let lines = feed.build_lines(&messages, &palette, 60, ru());
assert!(
!feed.cache_reset,
"a new query must not rebuild any block — that is the whole point"
);
assert_eq!(
accented(&lines, &palette),
vec!["метка"],
"and the new query is the one highlighted"
);
}
#[test]
fn tail_following_is_only_forced_by_user_initiated_scrolling() {
let mut feed = MessageFeed::new();
feed.scroll_up(3);
assert!(!feed.is_following());
feed.scroll_to_bottom_if_following();
assert!(
!feed.is_following(),
"arriving content must not force follow"
);
feed.scroll_to_bottom();
assert!(feed.is_following(), "user-initiated: back to the tail");
feed.scroll_to_bottom_if_following();
assert!(feed.is_following(), "already following — stays");
}
#[test]
fn scroll_up_disables_follow() {
let mut feed = MessageFeed::new();
assert!(feed.follow);
feed.scroll_up(3);
assert!(!feed.follow);
}
#[test]
fn scroll_sets_take_once_flag() {
let mut feed = MessageFeed::new();
assert!(!feed.take_scrolled(), "flag not raised before scrolling");
feed.scroll_up(3);
assert!(feed.take_scrolled(), "flag raised after scrolling");
assert!(!feed.take_scrolled(), "the flag is taken exactly once");
feed.scroll_down(3);
assert!(feed.take_scrolled());
}
#[test]
fn render_does_not_panic() {
let mut feed = MessageFeed::new();
let messages = vec![
msg(FeedRole::User, "привет", ""),
msg(
FeedRole::Assistant,
"# Заголовок\n\nответ с `кодом`",
"мысль",
),
FeedMessage::note("(генерация отменена)"),
];
let mut term = Terminal::new(TestBackend::new(40, 10)).unwrap();
term.draw(|f| {
feed.render(
f,
f.area(),
"Чат",
"gemma-4 · 16k ctx",
&messages,
&Palette::default(),
ru(),
)
})
.unwrap();
}
#[test]
fn a_long_chat_title_is_cut_on_the_border_and_marked() {
let top = |term: &Terminal<TestBackend>| -> String {
let buf = term.backend().buffer();
(buf.area.left()..buf.area.right())
.map(|x| buf[(x, buf.area.top())].symbol().to_string())
.collect()
};
let mut feed = MessageFeed::new();
let mut term = Terminal::new(TestBackend::new(60, 8)).unwrap();
let long = "заголовок который не помещается в верхнюю рамку никак";
let meta = "gemma-4 · 16k ctx";
term.draw(|f| feed.render(f, f.area(), long, meta, &[], &Palette::default(), ru()))
.unwrap();
let row = top(&term);
assert!(row.contains('…'), "a cut title says so: {row}");
assert!(row.contains(meta), "the meta keeps its corner: {row}");
assert!(row.starts_with('╭') && row.ends_with('╮'), "{row}");
let mut term = Terminal::new(TestBackend::new(100, 8)).unwrap();
term.draw(|f| feed.render(f, f.area(), long, meta, &[], &Palette::default(), ru()))
.unwrap();
let row = top(&term);
assert!(row.contains(long), "{row}");
assert!(!row.contains('…'), "{row}");
}
#[test]
fn scrollbar_appears_only_when_feed_overflows() {
let right_col = |term: &Terminal<TestBackend>| -> Vec<String> {
let buf = term.backend().buffer();
let area = buf.area;
(area.top()..area.bottom())
.map(|y| buf[(area.right() - 1, y)].symbol().to_string())
.collect()
};
let mut feed = MessageFeed::new();
let mut term = Terminal::new(TestBackend::new(30, 8)).unwrap();
let short = vec![msg(FeedRole::User, "привет", "")];
term.draw(|f| feed.render(f, f.area(), "Чат", "", &short, &Palette::default(), ru()))
.unwrap();
assert!(
!right_col(&term).iter().any(|s| s == "█"),
"a short feed — no scrollbar thumb"
);
let many: Vec<FeedMessage> = (0..30)
.map(|i| msg(FeedRole::User, &format!("строка {i}"), ""))
.collect();
term.draw(|f| feed.render(f, f.area(), "Чат", "", &many, &Palette::default(), ru()))
.unwrap();
assert!(
right_col(&term).iter().any(|s| s == "█"),
"an overflowing feed — with a scrollbar thumb"
);
}
fn line_sig(l: &Line<'static>) -> Vec<(String, Option<Color>)> {
l.spans
.iter()
.map(|s| (s.content.to_string(), s.style.fg))
.collect()
}
fn assert_warm_matches_fresh(messages: &[FeedMessage], compaction: Option<(Uuid, String)>) {
for width in [40usize, 80] {
for palette in [Palette::default(), Palette::default().with_compat(true)] {
for thoughts in [false, true] {
for tools in [false, true] {
let view = FeedView { thoughts, tools };
let mut warm = MessageFeed::new();
warm.set_view(view);
warm.set_compaction(compaction.clone());
let _ = warm.build_lines(messages, &palette, width, ru());
let _ = warm.build_lines(messages, &palette, width, ru());
let warm_lines = warm.build_lines(messages, &palette, width, ru());
let mut fresh = MessageFeed::new();
fresh.set_view(view);
fresh.set_compaction(compaction.clone());
let fresh_lines = fresh.build_lines(messages, &palette, width, ru());
let w: Vec<_> = warm_lines.iter().map(line_sig).collect();
let f: Vec<_> = fresh_lines.iter().map(line_sig).collect();
assert_eq!(
w,
f,
"cache diverged: width={width} view={view:?} \
compaction={}",
compaction.is_some()
);
}
}
}
}
}
#[test]
fn cache_matches_fresh_render() {
let tool_msg = {
let mut m = msg(FeedRole::Assistant, "готово", "");
m.tools.push(FeedToolCall {
name: "note_save".into(),
arguments: "{}".into(),
result: "ок".into(),
text_offset: m.text.len(),
images: 0,
call_id: None,
running: false,
});
m
};
let scenarios: Vec<Vec<FeedMessage>> = vec![
vec![msg(FeedRole::User, "привет как дела сегодня", "")],
vec![
msg(FeedRole::User, "вопрос", ""),
msg(
FeedRole::Assistant,
"# Заголовок\n\nответ с `кодом` и формулой $x^2 + \\alpha$",
"рассуждение модели",
),
],
vec![tool_msg],
];
for messages in &scenarios {
let folded = Some((messages[0].message_ids[0], "ранее обсудили X".to_string()));
for compaction in [None, folded] {
assert_warm_matches_fresh(messages, compaction);
}
}
}
#[test]
fn cache_matches_fresh_during_streaming() {
let mut warm = MessageFeed::new();
let palette = Palette::default();
let full = "Это ответ ассистента, который растёт по чанкам стриминга.";
let total = full.chars().count();
for end in (1..=total).step_by(3) {
let partial: String = full.chars().take(end).collect();
let mut m = msg(FeedRole::Assistant, &partial, "");
m.streaming = true;
let messages = vec![msg(FeedRole::User, "спроси", ""), m];
let warm_lines = warm.build_lines(&messages, &palette, 60, ru());
let mut fresh = MessageFeed::new();
let fresh_lines = fresh.build_lines(&messages, &palette, 60, ru());
let w: Vec<_> = warm_lines.iter().map(line_sig).collect();
let f: Vec<_> = fresh_lines.iter().map(line_sig).collect();
assert_eq!(w, f, "stream cache diverged at end={end}");
}
}
#[test]
fn cache_invalidates_on_message_edit() {
let mut feed = MessageFeed::new();
let palette = Palette::default();
let join = |ls: &[Line<'static>]| -> String {
ls.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.to_string()))
.collect()
};
let l1 = feed.build_lines(
&[msg(FeedRole::Assistant, "первый вариант", "")],
&palette,
80,
ru(),
);
assert!(join(&l1).contains("первый вариант"));
let l2 = feed.build_lines(
&[msg(FeedRole::Assistant, "другой текст", "")],
&palette,
80,
ru(),
);
let j2 = join(&l2);
assert!(j2.contains("другой текст"), "{j2}");
assert!(!j2.contains("первый вариант"), "stale cache: {j2}");
}
#[test]
fn cache_handles_history_truncation() {
let mut feed = MessageFeed::new();
let palette = Palette::default();
let long = vec![
msg(FeedRole::User, "раз", ""),
msg(FeedRole::Assistant, "ответ раз", ""),
msg(FeedRole::User, "два", ""),
msg(FeedRole::Assistant, "ответ два", ""),
];
let _ = feed.build_lines(&long, &palette, 80, ru());
let warm = feed.build_lines(&long[..2], &palette, 80, ru());
let mut fresh = MessageFeed::new();
let fresh_lines = fresh.build_lines(&long[..2], &palette, 80, ru());
let w: Vec<_> = warm.iter().map(line_sig).collect();
let f: Vec<_> = fresh_lines.iter().map(line_sig).collect();
assert_eq!(w, f);
}
fn compacted_label() -> &'static str {
ru().t("ui.feed.compacted")
}
fn divider_row(rows: &[String]) -> Option<usize> {
rows.iter().position(|r| r.contains(compacted_label()))
}
#[test]
fn compaction_divider_precedes_the_boundary_block() {
let messages = numbered(4);
let boundary = messages[2].message_ids[0];
let mut plain = MessageFeed::new();
let rows = row_texts(&plain.build_lines(&messages, &Palette::default(), 80, ru()));
assert!(
divider_row(&rows).is_none(),
"no compaction — no divider: {rows:?}"
);
let mut feed = MessageFeed::new();
feed.set_compaction(Some((boundary, "ранее обсудили X".into())));
let rows = row_texts(&feed.build_lines(&messages, &Palette::default(), 80, ru()));
let at = divider_row(&rows).expect("the divider must be drawn");
let target = rows
.iter()
.position(|r| r.contains("сообщение-2"))
.expect("the boundary message must still be shown");
let previous = rows
.iter()
.position(|r| r.contains("сообщение-1"))
.expect("the folded messages are still shown — only the request shrinks");
assert!(
previous < at && at < target,
"the divider belongs between the folded part and the boundary block, \
got previous={previous} divider={at} target={target}: {rows:?}"
);
}
#[test]
fn compaction_summary_folds_with_the_thoughts_blocks() {
let messages = numbered(3);
let mut feed = MessageFeed::new();
feed.set_compaction(Some((
messages[1].message_ids[0],
"ранее обсудили X".into(),
)));
let rows = row_texts(&feed.build_lines(&messages, &Palette::default(), 80, ru()));
let at = divider_row(&rows).expect("the divider must be drawn");
assert!(
!rows.iter().any(|r| r.contains("ранее обсудили X")),
"collapsed by default — the summary must not be on screen: {rows:?}"
);
assert!(
rows[at].contains("Ctrl+T"),
"the collapsed pill names the key that reveals it: {:?}",
rows[at]
);
feed.toggle_thoughts();
let rows = row_texts(&feed.build_lines(&messages, &Palette::default(), 80, ru()));
let at = divider_row(&rows).expect("the divider stays when expanded");
assert!(
rows.iter().any(|r| r.contains("ранее обсудили X")),
"Ctrl+T must reveal the summary: {rows:?}"
);
assert!(
!rows[at].contains("Ctrl+T"),
"expanded, the keycap drops away (as the thoughts block does): {:?}",
rows[at]
);
}
#[test]
fn compaction_with_an_unknown_boundary_draws_nothing() {
let messages = numbered(3);
let mut feed = MessageFeed::new();
feed.set_compaction(Some((Uuid::new_v4(), "ранее обсудили X".into())));
let rows = row_texts(&feed.build_lines(&messages, &Palette::default(), 80, ru()));
assert!(divider_row(&rows).is_none(), "{rows:?}");
assert!(
!rows.iter().any(|r| r.contains("ранее обсудили X")),
"{rows:?}"
);
}
#[test]
fn compaction_boundary_resolves_through_a_merged_bubble() {
use crate::entities::message::Message;
let user = Message::user("вопрос");
let r1 = Message::assistant("Ищу.");
let r2 = Message::assistant("Готово.");
let second_round = r2.id;
let messages = FeedMessage::from_messages(&[user, r1, r2]);
assert_eq!(messages.len(), 2, "the rounds merge into one bubble");
let mut feed = MessageFeed::new();
feed.set_compaction(Some((second_round, "ранее обсудили X".into())));
let rows = row_texts(&feed.build_lines(&messages, &Palette::default(), 80, ru()));
let at = divider_row(&rows).expect("a merged round's id must still resolve");
let bubble = rows.iter().position(|r| r.contains("Ищу.")).unwrap();
assert!(
at < bubble,
"the divider precedes the whole bubble: {rows:?}"
);
}
#[test]
fn changing_the_summary_invalidates_the_cache() {
let messages = numbered(3);
let boundary = messages[1].message_ids[0];
let mut feed = MessageFeed::new();
feed.toggle_thoughts(); feed.set_compaction(Some((boundary, "первое резюме".into())));
let rows = row_texts(&feed.build_lines(&messages, &Palette::default(), 80, ru()));
assert!(rows.iter().any(|r| r.contains("первое резюме")), "{rows:?}");
feed.set_compaction(Some((boundary, "второе резюме".into())));
let rows = row_texts(&feed.build_lines(&messages, &Palette::default(), 80, ru()));
assert!(rows.iter().any(|r| r.contains("второе резюме")), "{rows:?}");
assert!(
!rows.iter().any(|r| r.contains("первое резюме")),
"stale cache: {rows:?}"
);
feed.set_compaction(None);
let rows = row_texts(&feed.build_lines(&messages, &Palette::default(), 80, ru()));
assert!(divider_row(&rows).is_none(), "{rows:?}");
}
#[test]
fn compaction_divider_fills_the_panel_width() {
let messages = numbered(2);
for width in [60usize, 80] {
for palette in [Palette::default(), Palette::default().with_compat(true)] {
for expanded in [false, true] {
let mut feed = MessageFeed::new();
if expanded {
feed.toggle_thoughts();
}
feed.set_compaction(Some((
messages[1].message_ids[0],
"ранее обсудили X".into(),
)));
let lines = feed.build_lines(&messages, &palette, width, ru());
let rows = row_texts(&lines);
let at = divider_row(&rows).expect("the divider must be drawn");
assert_eq!(
line_width(&lines[at]),
width,
"the divider must span the panel at width={width} \
expanded={expanded} compat={}: {:?}",
palette.compat,
rows[at]
);
}
}
}
}
#[test]
fn compaction_divider_respects_the_wrap_invariant_when_cramped() {
let messages = numbered(2);
for width in [20usize, 30, 46] {
for expanded in [false, true] {
let mut feed = MessageFeed::new();
if expanded {
feed.toggle_thoughts();
}
feed.set_compaction(Some((
messages[1].message_ids[0],
"ранее обсудили X, а также Y и Z, и это довольно длинное резюме".into(),
)));
let lines = feed.build_lines(&messages, &Palette::default(), width, ru());
for line in &lines {
for wrapped in wrap::wrap_line(line, width) {
let text: String = row_texts(std::slice::from_ref(&wrapped)).remove(0);
let w = wrap::display_width(&text.trim_end().chars().collect::<Vec<_>>());
assert!(
w <= width,
"row {w} columns wide at width={width} \
expanded={expanded}: {text:?}"
);
}
}
}
}
}
fn line_width(line: &Line<'static>) -> usize {
line.spans
.iter()
.map(|s| wrap::display_width(&s.content.chars().collect::<Vec<_>>()))
.sum()
}
}