Codex Handbook
tui/src/history_cell/messages.rs 510 lines
//! User, assistant, reasoning, and streaming message history cells.use super::*;#[derive(Debug)]pub(crate) struct UserHistoryCell {    pub message: String,    pub text_elements: Vec<TextElement>,    #[allow(dead_code)]    pub local_image_paths: Vec<PathBuf>,    pub remote_image_urls: Vec<String>,}/// 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<Line<'static>> {    let mut elements = elements.to_vec();    elements.sort_by_key(|e| e.byte_range.start);    let mut offset = 0usize;    let mut raw_lines: Vec<Line<'static>> = 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<Span<'static>> = 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<Line<'static>>) -> Vec<Line<'static>> {    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<Line<'static>> {        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<Line<'static>> = 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<Line<'static>> {        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<Line<'static>> {        let mut lines: Vec<Line<'static>> = 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::<Vec<_>>();        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<Line<'static>> {        if self.transcript_only {            Vec::new()        } else {            self.lines(width)        }    }    fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {        self.lines(width)    }    fn raw_lines(&self) -> Vec<Line<'static>> {        if self.transcript_only {            Vec::new()        } else {            raw_lines_from_source(self.content.trim())        }    }}#[derive(Debug)]pub(crate) struct AgentMessageCell {    lines: Vec<HyperlinkLine>,    is_first_line: bool,}impl AgentMessageCell {    #[cfg(test)]    pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {        Self {            lines: plain_hyperlink_lines(lines),            is_first_line,        }    }    pub(crate) fn new_hyperlink_lines(lines: Vec<HyperlinkLine>, is_first_line: bool) -> Self {        Self {            lines,            is_first_line,        }    }}impl HistoryCell for AgentMessageCell {    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {        visible_lines(self.display_hyperlink_lines(width))    }    fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {        let mut wrapped = Vec::new();        for (index, line) in self.lines.iter().enumerate() {            let initial_indent = if index == 0 && self.is_first_line {                "• ".dim().into()            } else {                "  ".into()            };            let mut subsequent_indent = Line::from("  ");            subsequent_indent                .spans                .extend(crate::insert_history::leading_whitespace_prefix(&line.line).spans);            wrapped.extend(crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines(                std::slice::from_ref(line),                RtOptions::new(width as usize)                    .initial_indent(initial_indent)                    .subsequent_indent(subsequent_indent),            ));        }        wrapped    }    fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {        self.display_hyperlink_lines(width)    }    fn raw_lines(&self) -> Vec<Line<'static>> {        plain_lines(visible_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<Line<'static>> {        visible_lines(self.display_hyperlink_lines(width))    }    fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {        let Some(wrap_width) =            crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2)        else {            return prefix_hyperlink_lines(                vec![HyperlinkLine::new(Line::default())],                "• ".dim(),                "  ".into(),            );        };        // Re-render markdown from source at the current width. Reserve 2 columns for the "• " /        // " " prefix prepended below.        let lines = crate::markdown::render_markdown_agent_with_links_and_cwd(            &self.markdown_source,            Some(wrap_width),            Some(self.cwd.as_path()),        );        prefix_hyperlink_lines(lines, "• ".dim(), "  ".into())    }    fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {        self.display_hyperlink_lines(width)    }    fn raw_lines(&self) -> Vec<Line<'static>> {        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<HyperlinkLine>,    is_first_line: bool,}impl StreamingAgentTailCell {    pub(crate) fn new(lines: Vec<HyperlinkLine>, is_first_line: bool) -> Self {        Self {            lines,            is_first_line,        }    }}impl HistoryCell for StreamingAgentTailCell {    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {        visible_lines(self.display_hyperlink_lines(width))    }    fn display_hyperlink_lines(&self, _width: u16) -> Vec<HyperlinkLine> {        // 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.        let mut lines = prefix_hyperlink_lines(            self.lines.clone(),            if self.is_first_line {                "• ".dim()            } else {                "  ".into()            },            "  ".into(),        );        for line in &mut lines {            if line                .line                .spans                .iter()                .all(|span| span.content.chars().all(char::is_whitespace))            {                line.line = Line::default().style(line.line.style);                line.hyperlinks.clear();            }        }        lines    }    fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {        self.display_hyperlink_lines(width)    }    fn raw_lines(&self) -> Vec<Line<'static>> {        plain_lines(self.display_lines(/*width*/ u16::MAX))    }    fn is_stream_continuation(&self) -> bool {        !self.is_first_line    }}pub(crate) fn new_user_prompt(    message: String,    text_elements: Vec<TextElement>,    local_image_paths: Vec<PathBuf>,    remote_image_urls: Vec<String>,) -> 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<dyn HistoryCell> {    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,    ))}