//! User, assistant, reasoning, and streaming message history cells. use super::*; #[derive(Debug)] pub(crate) struct UserHistoryCell { pub message: String, pub text_elements: Vec, #[allow(dead_code)] pub local_image_paths: Vec, pub remote_image_urls: Vec, } /// Build logical lines for a user message with styled text elements. /// /// This preserves explicit newlines while interleaving element spans and skips /// malformed byte ranges instead of panicking during history rendering. fn build_user_message_lines_with_elements( message: &str, elements: &[TextElement], style: Style, element_style: Style, ) -> Vec> { let mut elements = elements.to_vec(); elements.sort_by_key(|e| e.byte_range.start); let mut offset = 0usize; let mut raw_lines: Vec> = Vec::new(); for line_text in message.split('\n') { let line_start = offset; let line_end = line_start + line_text.len(); let mut spans: Vec> = Vec::new(); // Track how much of the line we've emitted to interleave plain and styled spans. let mut cursor = line_start; for elem in &elements { let start = elem.byte_range.start.max(line_start); let end = elem.byte_range.end.min(line_end); if start >= end { continue; } let rel_start = start - line_start; let rel_end = end - line_start; // Guard against malformed UTF-8 byte ranges from upstream data; skip // invalid elements rather than panicking while rendering history. if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) { continue; } let rel_cursor = cursor - line_start; if cursor < start && line_text.is_char_boundary(rel_cursor) && let Some(segment) = line_text.get(rel_cursor..rel_start) { spans.push(Span::from(segment.to_string())); } if let Some(segment) = line_text.get(rel_start..rel_end) { spans.push(Span::styled(segment.to_string(), element_style)); cursor = end; } } let rel_cursor = cursor - line_start; if cursor < line_end && line_text.is_char_boundary(rel_cursor) && let Some(segment) = line_text.get(rel_cursor..) { spans.push(Span::from(segment.to_string())); } let line = if spans.is_empty() { Line::from(line_text.to_string()).style(style) } else { Line::from(spans).style(style) }; raw_lines.push(line); // Split on '\n' so any '\r' stays in the line; advancing by 1 accounts // for the separator byte. offset = line_end + 1; } raw_lines } fn remote_image_display_line(style: Style, index: usize) -> Line<'static> { Line::from(local_image_label_text(index)).style(style) } fn trim_trailing_blank_lines(mut lines: Vec>) -> Vec> { while lines .last() .is_some_and(|line| line.spans.iter().all(|span| span.content.trim().is_empty())) { lines.pop(); } lines } impl HistoryCell for UserHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let wrap_width = width .saturating_sub( LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */ ) .max(1); let style = user_message_style(); let element_style = style.fg(Color::Cyan); let wrapped_remote_images = if self.remote_image_urls.is_empty() { None } else { Some(adaptive_wrap_lines( self.remote_image_urls .iter() .enumerate() .map(|(idx, _url)| { remote_image_display_line(element_style, idx.saturating_add(1)) }), RtOptions::new(usize::from(wrap_width)) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), )) }; let wrapped_message = if self.message.is_empty() && self.text_elements.is_empty() { None } else if self.text_elements.is_empty() { let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']); let wrapped = adaptive_wrap_lines( message_without_trailing_newlines .split('\n') .map(|line| Line::from(line).style(style)), // Wrap algorithm matches textarea.rs. RtOptions::new(usize::from(wrap_width)) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), ); let wrapped = trim_trailing_blank_lines(wrapped); (!wrapped.is_empty()).then_some(wrapped) } else { let raw_lines = build_user_message_lines_with_elements( &self.message, &self.text_elements, style, element_style, ); let wrapped = adaptive_wrap_lines( raw_lines, RtOptions::new(usize::from(wrap_width)) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit), ); let wrapped = trim_trailing_blank_lines(wrapped); (!wrapped.is_empty()).then_some(wrapped) }; if wrapped_remote_images.is_none() && wrapped_message.is_none() { return Vec::new(); } let mut lines: Vec> = vec![Line::from("").style(style)]; if let Some(wrapped_remote_images) = wrapped_remote_images { lines.extend(prefix_lines( wrapped_remote_images, " ".into(), " ".into(), )); if wrapped_message.is_some() { lines.push(Line::from("").style(style)); } } if let Some(wrapped_message) = wrapped_message { lines.extend(prefix_lines( wrapped_message, "› ".bold().dim(), " ".into(), )); } lines.push(Line::from("").style(style)); lines } fn raw_lines(&self) -> Vec> { let mut lines = raw_lines_from_source(self.message.trim_end_matches(['\r', '\n'])); if !self.remote_image_urls.is_empty() { if !lines.is_empty() { lines.push(Line::from("")); } lines.extend( self.remote_image_urls .iter() .enumerate() .map(|(idx, _url)| Line::from(local_image_label_text(idx.saturating_add(1)))), ); } lines } } #[derive(Debug)] pub(crate) struct ReasoningSummaryCell { _header: String, content: String, /// Session cwd used to render local file links inside the reasoning body. cwd: PathBuf, transcript_only: bool, } impl ReasoningSummaryCell { /// Create a reasoning summary cell that will render local file links relative to the session /// cwd active when the summary was recorded. pub(crate) fn new(header: String, content: String, cwd: &Path, transcript_only: bool) -> Self { Self { _header: header, content, cwd: cwd.to_path_buf(), transcript_only, } } fn lines(&self, width: u16) -> Vec> { let mut lines: Vec> = Vec::new(); append_markdown( &self.content, crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2), Some(self.cwd.as_path()), &mut lines, ); let summary_style = Style::default().dim().italic(); let summary_lines = lines .into_iter() .map(|mut line| { line.spans = line .spans .into_iter() .map(|span| span.patch_style(summary_style)) .collect(); line }) .collect::>(); adaptive_wrap_lines( &summary_lines, RtOptions::new(width as usize) .initial_indent("• ".dim().into()) .subsequent_indent(" ".into()), ) } } impl HistoryCell for ReasoningSummaryCell { fn display_lines(&self, width: u16) -> Vec> { if self.transcript_only { Vec::new() } else { self.lines(width) } } fn transcript_lines(&self, width: u16) -> Vec> { self.lines(width) } fn raw_lines(&self) -> Vec> { if self.transcript_only { Vec::new() } else { raw_lines_from_source(self.content.trim()) } } } #[derive(Debug)] pub(crate) struct AgentMessageCell { lines: Vec>, is_first_line: bool, } impl AgentMessageCell { pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { Self { lines, is_first_line, } } } impl HistoryCell for AgentMessageCell { fn display_lines(&self, width: u16) -> Vec> { adaptive_wrap_lines( &self.lines, RtOptions::new(width as usize) .initial_indent(if self.is_first_line { "• ".dim().into() } else { " ".into() }) .subsequent_indent(" ".into()), ) } fn raw_lines(&self) -> Vec> { plain_lines(self.lines.clone()) } fn is_stream_continuation(&self) -> bool { !self.is_first_line } } /// A consolidated agent message cell that stores raw markdown source and re-renders from it. /// /// After a stream finalizes, the `ConsolidateAgentMessage` handler in `App` /// replaces the contiguous run of `AgentMessageCell`s with a single /// `AgentMarkdownCell`. On terminal resize, `display_lines(width)` re-renders /// from source via `append_markdown_agent`, producing correctly-sized tables /// with box-drawing borders. /// /// The cell snapshots `cwd` at construction so local file-link display remains aligned with the /// session that produced the message. Reusing the current process cwd during reflow would make old /// transcript content change meaning after a later `/cd` or resumed session. #[derive(Debug)] pub(crate) struct AgentMarkdownCell { markdown_source: String, cwd: PathBuf, } impl AgentMarkdownCell { /// Create a finalized source-backed assistant message cell. /// /// `markdown_source` must be the raw source accumulated by the stream controller, not already /// wrapped terminal lines. Passing rendered lines here would make future resize reflow preserve /// stale wrapping instead of repairing it. pub(crate) fn new(markdown_source: String, cwd: &Path) -> Self { Self { markdown_source, cwd: cwd.to_path_buf(), } } } impl HistoryCell for AgentMarkdownCell { fn display_lines(&self, width: u16) -> Vec> { let Some(wrap_width) = crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2) else { return prefix_lines(vec![Line::default()], "• ".dim(), " ".into()); }; let mut lines: Vec> = Vec::new(); // Re-render markdown from source at the current width. Reserve 2 columns for the "• " / // " " prefix prepended below. crate::markdown::append_markdown_agent_with_cwd( &self.markdown_source, Some(wrap_width), Some(self.cwd.as_path()), &mut lines, ); prefix_lines(lines, "• ".dim(), " ".into()) } fn raw_lines(&self) -> Vec> { raw_lines_from_source(&self.markdown_source) } } /// Transient active-cell representation of the mutable tail of an agent stream. /// /// During streaming, lines that have not yet been committed to scrollback because they belong to /// an in-progress table are displayed via this cell in the `active_cell` slot. It is replaced on /// every delta and cleared when the stream finalizes. #[derive(Debug)] pub(crate) struct StreamingAgentTailCell { lines: Vec>, is_first_line: bool, } impl StreamingAgentTailCell { pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { Self { lines, is_first_line, } } } impl HistoryCell for StreamingAgentTailCell { fn display_lines(&self, _width: u16) -> Vec> { // Tail lines are already rendered at the controller's current stream width. // Re-wrapping them here can split table borders and produce malformed in-flight rows. prefix_lines( self.lines.clone(), if self.is_first_line { "• ".dim() } else { " ".into() }, " ".into(), ) } fn raw_lines(&self) -> Vec> { plain_lines(self.display_lines(u16::MAX)) } fn is_stream_continuation(&self) -> bool { !self.is_first_line } } pub(crate) fn new_user_prompt( message: String, text_elements: Vec, local_image_paths: Vec, remote_image_urls: Vec, ) -> UserHistoryCell { UserHistoryCell { message, text_elements, local_image_paths, remote_image_urls, } } /// Create the reasoning history cell emitted at the end of a reasoning block. /// /// The helper snapshots `cwd` into the returned cell so local file links render the same way they /// did while the turn was live, even if rendering happens after other app state has advanced. pub(crate) fn new_reasoning_summary_block( full_reasoning_buffer: String, cwd: &Path, ) -> Box { let cwd = cwd.to_path_buf(); let full_reasoning_buffer = full_reasoning_buffer.trim(); if let Some(open) = full_reasoning_buffer.find("**") { let after_open = &full_reasoning_buffer[(open + 2)..]; if let Some(close) = after_open.find("**") { let after_close_idx = open + 2 + close + 2; // if we don't have anything beyond `after_close_idx` // then we don't have a summary to inject into history if after_close_idx < full_reasoning_buffer.len() { let header_buffer = full_reasoning_buffer[..after_close_idx].to_string(); let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string(); // Preserve the session cwd so local file links render the same way in the // collapsed reasoning block as they did while streaming live content. return Box::new(ReasoningSummaryCell::new( header_buffer, summary_buffer, &cwd, /*transcript_only*/ false, )); } } } Box::new(ReasoningSummaryCell::new( "".to_string(), full_reasoning_buffer.to_string(), &cwd, /*transcript_only*/ true, )) }