mirror of
https://github.com/openai/codex.git
synced 2026-11-05 00:27:29 +00:00
## Why `codex-rs/tui/src/history_cell.rs` had become the dumping ground for transcript rendering: the shared trait, common helpers, and the concrete cells for messages, plans, MCP/search, notices, patches, approvals, session chrome, and separators all lived together. That made small transcript changes require reopening a very large file and made ownership less obvious. ## What changed - Replaced the monolithic `history_cell.rs` with a `history_cell/` module tree organized by concern. - Kept the existing `crate::history_cell::*` surface stable through re-exports in `history_cell/mod.rs`. - Moved the existing render coverage into `history_cell/tests.rs`. ## Reviewer notes - This PR is intentionally mechanical in mature — existing code and tests moving into files that match their concern. - The snapshot files under `codex-rs/tui/src/history_cell/snapshots/` moved with the extracted test module. `insta` resolves these unnamed snapshots relative to the source file that declares them, so this is path churn only; snapshot contents were not updated. - The small non-mechanical seam edits are limited to split fallout: sibling-module visibility for shared cell containers, moving approval-specific exec-snippet helpers beside approvals, fixing the separator module path, and keeping a couple of existing test helpers reachable after extraction.
456 lines
15 KiB
Rust
456 lines
15 KiB
Rust
//! 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<Line<'static>>,
|
||
is_first_line: bool,
|
||
}
|
||
|
||
impl AgentMessageCell {
|
||
pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {
|
||
Self {
|
||
lines,
|
||
is_first_line,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl HistoryCell for AgentMessageCell {
|
||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||
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<Line<'static>> {
|
||
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<Line<'static>> {
|
||
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<Line<'static>> = 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<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<Line<'static>>,
|
||
is_first_line: bool,
|
||
}
|
||
|
||
impl StreamingAgentTailCell {
|
||
pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {
|
||
Self {
|
||
lines,
|
||
is_first_line,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl HistoryCell for StreamingAgentTailCell {
|
||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||
// 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<Line<'static>> {
|
||
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<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,
|
||
))
|
||
}
|