Files
codex/codex-rs/tui/src/history_cell.rs
2026-02-27 10:14:13 -08:00

3947 lines
135 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Transcript/history cells for the Codex TUI.
//!
//! A `HistoryCell` is the unit of display in the conversation UI, representing both committed
//! transcript entries and, transiently, an in-flight active cell that can mutate in place while
//! streaming.
//!
//! The transcript overlay (`Ctrl+T`) appends a cached live tail derived from the active cell, and
//! that cached tail is refreshed based on an active-cell cache key. Cells that change based on
//! elapsed time expose `transcript_animation_tick()`, and code that mutates the active cell in place
//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the
//! rendered transcript output can change.
use crate::chatwidget::DEFAULT_MODEL_DISPLAY_NAME;
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::OutputLinesParams;
use crate::exec_cell::TOOL_CALL_MAX_LINES;
use crate::exec_cell::output_lines;
use crate::exec_cell::spinner;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::live_wrap::take_prefix_by_width;
use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::render::renderable::Renderable;
use crate::style::proposed_plan_style;
use crate::style::user_message_style;
use crate::text_formatting::format_and_truncate_tool_result;
use crate::text_formatting::truncate_text;
use crate::tooltips;
use crate::ui_consts::LIVE_PREFIX_COLS;
use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::adaptive_wrap_lines;
use base64::Engine;
use codex_core::config::Config;
use codex_core::config::types::McpServerTransportConfig;
use codex_core::web_search::web_search_detail;
use codex_otel::RuntimeMetricsSummary;
use codex_protocol::account::PlanType;
use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceTemplate;
use codex_protocol::models::WebSearchAction;
use codex_protocol::models::local_image_label_text;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::plan_tool::PlanItemArg;
use codex_protocol::plan_tool::StepStatus;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use codex_protocol::user_input::TextElement;
use codex_utils_cli::format_env_display::format_env_display;
use image::DynamicImage;
use image::ImageReader;
use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Styled;
use ratatui::style::Stylize;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::any::Any;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
use tracing::error;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
/// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list.
/// A single renderable unit of conversation history.
///
/// Each cell produces logical `Line`s and reports how many viewport
/// rows those lines occupy at a given terminal width. The default
/// height implementations use `Paragraph::wrap` to account for lines
/// that overflow the viewport width (e.g. long URLs that are kept
/// intact by adaptive wrapping). Concrete types only need to override
/// heights when they apply additional layout logic beyond what
/// `Paragraph::line_count` captures.
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
/// Returns the logical lines for the main chat viewport.
fn display_lines(&self, width: u16) -> Vec<Line<'static>>;
/// Returns the number of viewport rows needed to render this cell.
///
/// The default delegates to `Paragraph::line_count` with
/// `Wrap { trim: false }`, which measures the actual row count after
/// ratatui's viewport-level character wrapping. This is critical
/// for lines containing URL-like tokens that are wider than the
/// terminal — the logical line count would undercount.
fn desired_height(&self, width: u16) -> u16 {
Paragraph::new(Text::from(self.display_lines(width)))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
/// Returns lines for the transcript overlay (`Ctrl+T`).
///
/// Defaults to `display_lines`. Override when the transcript
/// representation differs (e.g. `ExecCell` shows all calls with
/// `$`-prefixed commands and exit status).
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.display_lines(width)
}
/// Returns the number of viewport rows for the transcript overlay.
///
/// Uses the same `Paragraph::line_count` measurement as
/// `desired_height`. Contains a workaround for a ratatui bug where
/// a single whitespace-only line reports 2 rows instead of 1.
fn desired_transcript_height(&self, width: u16) -> u16 {
let lines = self.transcript_lines(width);
// Workaround: ratatui's line_count returns 2 for a single
// whitespace-only line. Clamp to 1 in that case.
if let [line] = &lines[..]
&& line
.spans
.iter()
.all(|s| s.content.chars().all(char::is_whitespace))
{
return 1;
}
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.line_count(width)
.try_into()
.unwrap_or(0)
}
fn is_stream_continuation(&self) -> bool {
false
}
/// Returns a coarse "animation tick" when transcript output is time-dependent.
///
/// The transcript overlay caches the rendered output of the in-flight active cell, so cells
/// that include time-based UI (spinner, shimmer, etc.) should return a tick that changes over
/// time to signal that the cached tail should be recomputed. Returning `None` means the
/// transcript lines are stable, while returning `Some(tick)` during an in-flight animation
/// allows the overlay to keep up with the main viewport.
///
/// If a cell uses time-based visuals but always returns `None`, `Ctrl+T` can appear "frozen" on
/// the first rendered frame even though the main viewport is animating.
fn transcript_animation_tick(&self) -> Option<u64> {
None
}
}
impl Renderable for Box<dyn HistoryCell> {
fn render(&self, area: Rect, buf: &mut Buffer) {
let lines = self.display_lines(area.width);
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
let y = if area.height == 0 {
0
} else {
let overflow = paragraph
.line_count(area.width)
.saturating_sub(usize::from(area.height));
u16::try_from(overflow).unwrap_or(u16::MAX)
};
paragraph.scroll((y, 0)).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
HistoryCell::desired_height(self.as_ref(), width)
}
}
impl dyn HistoryCell {
pub(crate) fn as_any(&self) -> &dyn Any {
self
}
pub(crate) fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
}
#[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
}
}
#[derive(Debug)]
pub(crate) struct ReasoningSummaryCell {
_header: String,
content: String,
transcript_only: bool,
}
impl ReasoningSummaryCell {
pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self {
Self {
_header: header,
content,
transcript_only,
}
}
fn lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
append_markdown(
&self.content,
Some((width as usize).saturating_sub(2)),
&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)
}
}
#[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 is_stream_continuation(&self) -> bool {
!self.is_first_line
}
}
#[derive(Debug)]
pub(crate) struct PlainHistoryCell {
lines: Vec<Line<'static>>,
}
impl PlainHistoryCell {
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
Self { lines }
}
}
impl HistoryCell for PlainHistoryCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
}
#[cfg_attr(debug_assertions, allow(dead_code))]
#[derive(Debug)]
pub(crate) struct UpdateAvailableHistoryCell {
latest_version: String,
update_action: Option<UpdateAction>,
}
#[cfg_attr(debug_assertions, allow(dead_code))]
impl UpdateAvailableHistoryCell {
pub(crate) fn new(latest_version: String, update_action: Option<UpdateAction>) -> Self {
Self {
latest_version,
update_action,
}
}
}
impl HistoryCell for UpdateAvailableHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
use ratatui_macros::line;
use ratatui_macros::text;
let update_instruction = if let Some(update_action) = self.update_action {
line!["Run ", update_action.command_str().cyan(), " to update."]
} else {
line![
"See ",
"https://github.com/openai/codex".cyan().underlined(),
" for installation options."
]
};
let content = text![
line![
padded_emoji("").bold().cyan(),
"Update available!".bold().cyan(),
" ",
format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(),
],
update_instruction,
"",
"See full release notes:",
"https://github.com/openai/codex/releases/latest"
.cyan()
.underlined(),
];
let inner_width = content
.width()
.min(usize::from(width.saturating_sub(4)))
.max(1);
with_border_with_inner_width(content.lines, inner_width)
}
}
#[derive(Debug)]
pub(crate) struct PrefixedWrappedHistoryCell {
text: Text<'static>,
initial_prefix: Line<'static>,
subsequent_prefix: Line<'static>,
}
impl PrefixedWrappedHistoryCell {
pub(crate) fn new(
text: impl Into<Text<'static>>,
initial_prefix: impl Into<Line<'static>>,
subsequent_prefix: impl Into<Line<'static>>,
) -> Self {
Self {
text: text.into(),
initial_prefix: initial_prefix.into(),
subsequent_prefix: subsequent_prefix.into(),
}
}
}
impl HistoryCell for PrefixedWrappedHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
}
let opts = RtOptions::new(width.max(1) as usize)
.initial_indent(self.initial_prefix.clone())
.subsequent_indent(self.subsequent_prefix.clone());
adaptive_wrap_lines(&self.text, opts)
}
}
#[derive(Debug)]
pub(crate) struct UnifiedExecInteractionCell {
command_display: Option<String>,
stdin: String,
}
impl UnifiedExecInteractionCell {
pub(crate) fn new(command_display: Option<String>, stdin: String) -> Self {
Self {
command_display,
stdin,
}
}
}
impl HistoryCell for UnifiedExecInteractionCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
}
let wrap_width = width as usize;
let waited_only = self.stdin.is_empty();
let mut header_spans = if waited_only {
vec!["• Waited for background terminal".bold()]
} else {
vec!["".dim(), "Interacted with background terminal".bold()]
};
if let Some(command) = &self.command_display
&& !command.is_empty()
{
header_spans.push(" · ".dim());
header_spans.push(command.clone().dim());
}
let header = Line::from(header_spans);
let mut out: Vec<Line<'static>> = Vec::new();
let header_wrapped = adaptive_wrap_line(&header, RtOptions::new(wrap_width));
push_owned_lines(&header_wrapped, &mut out);
if waited_only {
return out;
}
let input_lines: Vec<Line<'static>> = self
.stdin
.lines()
.map(|line| Line::from(line.to_string()))
.collect();
let input_wrapped = adaptive_wrap_lines(
input_lines,
RtOptions::new(wrap_width)
.initial_indent(Line::from("".dim()))
.subsequent_indent(Line::from(" ".dim())),
);
out.extend(input_wrapped);
out
}
}
pub(crate) fn new_unified_exec_interaction(
command_display: Option<String>,
stdin: String,
) -> UnifiedExecInteractionCell {
UnifiedExecInteractionCell::new(command_display, stdin)
}
#[derive(Debug)]
struct UnifiedExecProcessesCell {
processes: Vec<UnifiedExecProcessDetails>,
}
impl UnifiedExecProcessesCell {
fn new(processes: Vec<UnifiedExecProcessDetails>) -> Self {
Self { processes }
}
}
#[derive(Debug, Clone)]
pub(crate) struct UnifiedExecProcessDetails {
pub(crate) command_display: String,
pub(crate) recent_chunks: Vec<String>,
}
impl HistoryCell for UnifiedExecProcessesCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
}
let wrap_width = width as usize;
let max_processes = 16usize;
let mut out: Vec<Line<'static>> = Vec::new();
out.push(vec!["Background terminals".bold()].into());
out.push("".into());
if self.processes.is_empty() {
out.push(" • No background terminals running.".italic().into());
return out;
}
let prefix = "";
let prefix_width = UnicodeWidthStr::width(prefix);
let truncation_suffix = " [...]";
let truncation_suffix_width = UnicodeWidthStr::width(truncation_suffix);
let mut shown = 0usize;
for process in &self.processes {
if shown >= max_processes {
break;
}
let command = &process.command_display;
let (snippet, snippet_truncated) = {
let (first_line, has_more_lines) = match command.split_once('\n') {
Some((first, _)) => (first, true),
None => (command.as_str(), false),
};
let max_graphemes = 80;
let mut graphemes = first_line.grapheme_indices(true);
if let Some((byte_index, _)) = graphemes.nth(max_graphemes) {
(first_line[..byte_index].to_string(), true)
} else {
(first_line.to_string(), has_more_lines)
}
};
if wrap_width <= prefix_width {
out.push(Line::from(prefix.dim()));
shown += 1;
continue;
}
let budget = wrap_width.saturating_sub(prefix_width);
let mut needs_suffix = snippet_truncated;
if !needs_suffix {
let (_, remainder, _) = take_prefix_by_width(&snippet, budget);
if !remainder.is_empty() {
needs_suffix = true;
}
}
if needs_suffix && budget > truncation_suffix_width {
let available = budget.saturating_sub(truncation_suffix_width);
let (truncated, _, _) = take_prefix_by_width(&snippet, available);
out.push(vec![prefix.dim(), truncated.cyan(), truncation_suffix.dim()].into());
} else {
let (truncated, _, _) = take_prefix_by_width(&snippet, budget);
out.push(vec![prefix.dim(), truncated.cyan()].into());
}
let chunk_prefix_first = "";
let chunk_prefix_next = " ";
for (idx, chunk) in process.recent_chunks.iter().enumerate() {
let chunk_prefix = if idx == 0 {
chunk_prefix_first
} else {
chunk_prefix_next
};
let chunk_prefix_width = UnicodeWidthStr::width(chunk_prefix);
if wrap_width <= chunk_prefix_width {
out.push(Line::from(chunk_prefix.dim()));
continue;
}
let budget = wrap_width.saturating_sub(chunk_prefix_width);
let (truncated, remainder, _) = take_prefix_by_width(chunk, budget);
if !remainder.is_empty() && budget > truncation_suffix_width {
let available = budget.saturating_sub(truncation_suffix_width);
let (shorter, _, _) = take_prefix_by_width(chunk, available);
out.push(
vec![chunk_prefix.dim(), shorter.dim(), truncation_suffix.dim()].into(),
);
} else {
out.push(vec![chunk_prefix.dim(), truncated.dim()].into());
}
}
shown += 1;
}
let remaining = self.processes.len().saturating_sub(shown);
if remaining > 0 {
let more_text = format!("... and {remaining} more running");
if wrap_width <= prefix_width {
out.push(Line::from(prefix.dim()));
} else {
let budget = wrap_width.saturating_sub(prefix_width);
let (truncated, _, _) = take_prefix_by_width(&more_text, budget);
out.push(vec![prefix.dim(), truncated.dim()].into());
}
}
out
}
fn desired_height(&self, width: u16) -> u16 {
self.display_lines(width).len() as u16
}
}
pub(crate) fn new_unified_exec_processes_output(
processes: Vec<UnifiedExecProcessDetails>,
) -> CompositeHistoryCell {
let command = PlainHistoryCell::new(vec!["/ps".magenta().into()]);
let summary = UnifiedExecProcessesCell::new(processes);
CompositeHistoryCell::new(vec![Box::new(command), Box::new(summary)])
}
fn truncate_exec_snippet(full_cmd: &str) -> String {
let mut snippet = match full_cmd.split_once('\n') {
Some((first, _)) => format!("{first} ..."),
None => full_cmd.to_string(),
};
snippet = truncate_text(&snippet, 80);
snippet
}
fn exec_snippet(command: &[String]) -> String {
let full_cmd = strip_bash_lc_and_escape(command);
truncate_exec_snippet(&full_cmd)
}
pub fn new_approval_decision_cell(
command: Vec<String>,
decision: codex_protocol::protocol::ReviewDecision,
) -> Box<dyn HistoryCell> {
use codex_protocol::protocol::ReviewDecision::*;
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
Approved => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".green(),
vec![
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet,
" this time".bold(),
],
)
}
ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment,
} => {
let snippet = Span::from(exec_snippet(&proposed_execpolicy_amendment.command)).dim();
(
"".green(),
vec![
"You ".into(),
"approved".bold(),
" codex to always run commands that start with ".into(),
snippet,
],
)
}
ApprovedForSession => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".green(),
vec![
"You ".into(),
"approved".bold(),
" codex to run ".into(),
snippet,
" every time this session".bold(),
],
)
}
NetworkPolicyAmendment {
network_policy_amendment,
} => {
let host = Span::from(network_policy_amendment.host).dim();
match network_policy_amendment.action {
codex_protocol::protocol::NetworkPolicyRuleAction::Allow => (
"".green(),
vec![
"You ".into(),
"approved".bold(),
" future network access to ".into(),
host,
],
),
codex_protocol::protocol::NetworkPolicyRuleAction::Deny => (
"".red(),
vec![
"You ".into(),
"blocked".bold(),
" future network access to ".into(),
host,
],
),
}
}
Denied => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".red(),
vec![
"You ".into(),
"did not approve".bold(),
" codex to run ".into(),
snippet,
],
)
}
Abort => {
let snippet = Span::from(exec_snippet(&command)).dim();
(
"".red(),
vec![
"You ".into(),
"canceled".bold(),
" the request to run ".into(),
snippet,
],
)
}
};
Box::new(PrefixedWrappedHistoryCell::new(
Line::from(summary),
symbol,
" ",
))
}
/// Cyan history cell line showing the current review status.
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
PlainHistoryCell {
lines: vec![Line::from(message.cyan())],
}
}
#[derive(Debug)]
pub(crate) struct PatchHistoryCell {
changes: HashMap<PathBuf, FileChange>,
cwd: PathBuf,
}
impl HistoryCell for PatchHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
create_diff_summary(&self.changes, &self.cwd, width as usize)
}
}
#[derive(Debug)]
struct CompletedMcpToolCallWithImageOutput {
_image: DynamicImage,
}
impl HistoryCell for CompletedMcpToolCallWithImageOutput {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec!["tool result (image output)".into()]
}
}
pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value
pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option<usize> {
if width < 4 {
return None;
}
let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width);
Some(inner_width)
}
/// Render `lines` inside a border sized to the widest span in the content.
pub(crate) fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
with_border_internal(lines, None)
}
/// Render `lines` inside a border whose inner width is at least `inner_width`.
///
/// This is useful when callers have already clamped their content to a
/// specific width and want the border math centralized here instead of
/// duplicating padding logic in the TUI widgets themselves.
pub(crate) fn with_border_with_inner_width(
lines: Vec<Line<'static>>,
inner_width: usize,
) -> Vec<Line<'static>> {
with_border_internal(lines, Some(inner_width))
}
fn with_border_internal(
lines: Vec<Line<'static>>,
forced_inner_width: Option<usize>,
) -> Vec<Line<'static>> {
let max_line_width = lines
.iter()
.map(|line| {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum::<usize>()
})
.max()
.unwrap_or(0);
let content_width = forced_inner_width
.unwrap_or(max_line_width)
.max(max_line_width);
let mut out = Vec::with_capacity(lines.len() + 2);
let border_inner_width = content_width + 2;
out.push(vec![format!("{}", "".repeat(border_inner_width)).dim()].into());
for line in lines.into_iter() {
let used_width: usize = line
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.sum();
let span_count = line.spans.len();
let mut spans: Vec<Span<'static>> = Vec::with_capacity(span_count + 4);
spans.push(Span::from("").dim());
spans.extend(line.into_iter());
if used_width < content_width {
spans.push(Span::from(" ".repeat(content_width - used_width)).dim());
}
spans.push(Span::from("").dim());
out.push(Line::from(spans));
}
out.push(vec![format!("{}", "".repeat(border_inner_width)).dim()].into());
out
}
/// Return the emoji followed by a hair space (U+200A).
/// Using only the hair space avoids excessive padding after the emoji while
/// still providing a small visual gap across terminals.
pub(crate) fn padded_emoji(emoji: &str) -> String {
format!("{emoji}\u{200A}")
}
#[derive(Debug)]
struct TooltipHistoryCell {
tip: String,
}
impl TooltipHistoryCell {
fn new(tip: String) -> Self {
Self { tip }
}
}
impl HistoryCell for TooltipHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let indent = " ";
let indent_width = UnicodeWidthStr::width(indent);
let wrap_width = usize::from(width.max(1))
.saturating_sub(indent_width)
.max(1);
let mut lines: Vec<Line<'static>> = Vec::new();
append_markdown(
&format!("**Tip:** {}", self.tip),
Some(wrap_width),
&mut lines,
);
prefix_lines(lines, indent.into(), indent.into())
}
}
pub(crate) fn new_session_info_body(
config: &Config,
requested_model: &str,
event: &SessionConfiguredEvent,
is_first_event: bool,
auth_plan: Option<PlanType>,
) -> Option<Box<dyn HistoryCell>> {
let parts = session_info_body_parts(config, requested_model, event, is_first_event, auth_plan);
match parts.len() {
0 => None,
1 => parts.into_iter().next(),
_ => Some(Box::new(CompositeHistoryCell::new(parts))),
}
}
fn session_info_body_parts(
config: &Config,
requested_model: &str,
event: &SessionConfiguredEvent,
is_first_event: bool,
auth_plan: Option<PlanType>,
) -> Vec<Box<dyn HistoryCell>> {
let mut parts: Vec<Box<dyn HistoryCell>> = Vec::new();
if is_first_event {
let help_lines: Vec<Line<'static>> = vec![
" To get started, describe a task or try one of these commands:"
.dim()
.into(),
Line::from(""),
Line::from(vec![
" ".into(),
"/init".into(),
" - create an AGENTS.md file with instructions for Codex".dim(),
]),
Line::from(vec![
" ".into(),
"/status".into(),
" - show current session configuration".dim(),
]),
Line::from(vec![
" ".into(),
"/permissions".into(),
" - choose what Codex is allowed to do".dim(),
]),
Line::from(vec![
" ".into(),
"/model".into(),
" - choose what model and reasoning effort to use".dim(),
]),
Line::from(vec![
" ".into(),
"/review".into(),
" - review any changes and find issues".dim(),
]),
];
parts.push(Box::new(PlainHistoryCell { lines: help_lines }));
} else {
if config.show_tooltips
&& let Some(tooltips) = tooltips::get_tooltip(auth_plan).map(TooltipHistoryCell::new)
{
parts.push(Box::new(tooltips));
}
if requested_model != event.model {
let lines = vec![
"model changed:".magenta().bold().into(),
format!("requested: {requested_model}").into(),
format!("used: {}", event.model).into(),
];
parts.push(Box::new(PlainHistoryCell { lines }));
}
}
parts
}
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,
}
}
#[derive(Debug)]
pub(crate) struct SessionHeaderHistoryCell {
version: &'static str,
model: String,
model_style: Style,
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
}
impl SessionHeaderHistoryCell {
pub(crate) fn new(
model: String,
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
version: &'static str,
) -> Self {
Self::new_with_style(
model,
Style::default(),
reasoning_effort,
directory,
version,
)
}
pub(crate) fn new_with_style(
model: String,
model_style: Style,
reasoning_effort: Option<ReasoningEffortConfig>,
directory: PathBuf,
version: &'static str,
) -> Self {
Self {
version,
model,
model_style,
reasoning_effort,
directory,
}
}
fn format_directory(&self, max_width: Option<usize>) -> String {
Self::format_directory_inner(&self.directory, max_width)
}
fn format_directory_inner(directory: &Path, max_width: Option<usize>) -> String {
let formatted = if let Some(rel) = relativize_to_home(directory) {
if rel.as_os_str().is_empty() {
"~".to_string()
} else {
format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
}
} else {
directory.display().to_string()
};
if let Some(max_width) = max_width {
if max_width == 0 {
return String::new();
}
if UnicodeWidthStr::width(formatted.as_str()) > max_width {
return crate::text_formatting::center_truncate_path(&formatted, max_width);
}
}
formatted
}
fn reasoning_label(&self) -> Option<&'static str> {
self.reasoning_effort.map(|effort| match effort {
ReasoningEffortConfig::Minimal => "minimal",
ReasoningEffortConfig::Low => "low",
ReasoningEffortConfig::Medium => "medium",
ReasoningEffortConfig::High => "high",
ReasoningEffortConfig::XHigh => "xhigh",
ReasoningEffortConfig::None => "none",
})
}
pub(crate) fn is_loading_placeholder(&self) -> bool {
self.model == DEFAULT_MODEL_DISPLAY_NAME
}
}
impl HistoryCell for SessionHeaderHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else {
return Vec::new();
};
let make_row = |spans: Vec<Span<'static>>| Line::from(spans);
// Title line rendered inside the box: ">_ OpenAI Codex (vX)"
let title_spans: Vec<Span<'static>> = vec![
Span::from(">_ ").dim(),
Span::from("OpenAI Codex").bold(),
Span::from(" ").dim(),
Span::from(format!("(v{})", self.version)).dim(),
];
const CHANGE_MODEL_HINT_COMMAND: &str = "/model";
const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change";
const DIR_LABEL: &str = "directory:";
let label_width = DIR_LABEL.len();
let model_label = format!(
"{model_label:<label_width$}",
model_label = "model:",
label_width = label_width
);
let reasoning_label = self.reasoning_label();
let model_spans: Vec<Span<'static>> = {
let mut spans = vec![
Span::from(format!("{model_label} ")).dim(),
Span::styled(self.model.clone(), self.model_style),
];
if let Some(reasoning) = reasoning_label {
spans.push(Span::from(" "));
spans.push(Span::from(reasoning));
}
spans.push(" ".dim());
spans.push(CHANGE_MODEL_HINT_COMMAND.cyan());
spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim());
spans
};
let dir_label = format!("{DIR_LABEL:<label_width$}");
let dir_prefix = format!("{dir_label} ");
let dir_prefix_width = UnicodeWidthStr::width(dir_prefix.as_str());
let dir_max_width = inner_width.saturating_sub(dir_prefix_width);
let dir = self.format_directory(Some(dir_max_width));
let dir_spans = vec![Span::from(dir_prefix).dim(), Span::from(dir)];
let lines = vec![
make_row(title_spans),
make_row(Vec::new()),
make_row(model_spans),
make_row(dir_spans),
];
with_border(lines)
}
}
#[derive(Debug)]
pub(crate) struct CompositeHistoryCell {
parts: Vec<Box<dyn HistoryCell>>,
}
impl CompositeHistoryCell {
pub(crate) fn new(parts: Vec<Box<dyn HistoryCell>>) -> Self {
Self { parts }
}
}
impl HistoryCell for CompositeHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
let mut first = true;
for part in &self.parts {
let mut lines = part.display_lines(width);
if !lines.is_empty() {
if !first {
out.push(Line::from(""));
}
out.append(&mut lines);
first = false;
}
}
out
}
}
#[derive(Debug)]
pub(crate) struct McpToolCallCell {
call_id: String,
invocation: McpInvocation,
start_time: Instant,
duration: Option<Duration>,
result: Option<Result<codex_protocol::mcp::CallToolResult, String>>,
animations_enabled: bool,
}
impl McpToolCallCell {
pub(crate) fn new(
call_id: String,
invocation: McpInvocation,
animations_enabled: bool,
) -> Self {
Self {
call_id,
invocation,
start_time: Instant::now(),
duration: None,
result: None,
animations_enabled,
}
}
pub(crate) fn call_id(&self) -> &str {
&self.call_id
}
pub(crate) fn complete(
&mut self,
duration: Duration,
result: Result<codex_protocol::mcp::CallToolResult, String>,
) -> Option<Box<dyn HistoryCell>> {
let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result)
.map(|cell| Box::new(cell) as Box<dyn HistoryCell>);
self.duration = Some(duration);
self.result = Some(result);
image_cell
}
fn success(&self) -> Option<bool> {
match self.result.as_ref() {
Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)),
Some(Err(_)) => Some(false),
None => None,
}
}
pub(crate) fn mark_failed(&mut self) {
let elapsed = self.start_time.elapsed();
self.duration = Some(elapsed);
self.result = Some(Err("interrupted".to_string()));
}
fn render_content_block(block: &serde_json::Value, width: usize) -> String {
let content = match serde_json::from_value::<rmcp::model::Content>(block.clone()) {
Ok(content) => content,
Err(_) => {
return format_and_truncate_tool_result(
&block.to_string(),
TOOL_CALL_MAX_LINES,
width,
);
}
};
match content.raw {
rmcp::model::RawContent::Text(text) => {
format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width)
}
rmcp::model::RawContent::Image(_) => "<image content>".to_string(),
rmcp::model::RawContent::Audio(_) => "<audio content>".to_string(),
rmcp::model::RawContent::Resource(resource) => {
let uri = match resource.resource {
rmcp::model::ResourceContents::TextResourceContents { uri, .. } => uri,
rmcp::model::ResourceContents::BlobResourceContents { uri, .. } => uri,
};
format!("embedded resource: {uri}")
}
rmcp::model::RawContent::ResourceLink(link) => format!("link: {}", link.uri),
}
}
}
impl HistoryCell for McpToolCallCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let status = self.success();
let bullet = match status {
Some(true) => "".green().bold(),
Some(false) => "".red().bold(),
None => spinner(Some(self.start_time), self.animations_enabled),
};
let header_text = if status.is_some() {
"Called"
} else {
"Calling"
};
let invocation_line = line_to_static(&format_mcp_invocation(self.invocation.clone()));
let mut compact_spans = vec![bullet.clone(), " ".into(), header_text.bold(), " ".into()];
let mut compact_header = Line::from(compact_spans.clone());
let reserved = compact_header.width();
let inline_invocation =
invocation_line.width() <= (width as usize).saturating_sub(reserved);
if inline_invocation {
compact_header.extend(invocation_line.spans.clone());
lines.push(compact_header);
} else {
compact_spans.pop(); // drop trailing space for standalone header
lines.push(Line::from(compact_spans));
let opts = RtOptions::new((width as usize).saturating_sub(4))
.initial_indent("".into())
.subsequent_indent(" ".into());
let wrapped = adaptive_wrap_line(&invocation_line, opts);
let body_lines: Vec<Line<'static>> = wrapped.iter().map(line_to_static).collect();
lines.extend(prefix_lines(body_lines, "".dim(), " ".into()));
}
let mut detail_lines: Vec<Line<'static>> = Vec::new();
// Reserve four columns for the tree prefix (" └ "/" ") and ensure the wrapper still has at least one cell to work with.
let detail_wrap_width = (width as usize).saturating_sub(4).max(1);
if let Some(result) = &self.result {
match result {
Ok(codex_protocol::mcp::CallToolResult { content, .. }) => {
if !content.is_empty() {
for block in content {
let text = Self::render_content_block(block, detail_wrap_width);
for segment in text.split('\n') {
let line = Line::from(segment.to_string().dim());
let wrapped = adaptive_wrap_line(
&line,
RtOptions::new(detail_wrap_width)
.initial_indent("".into())
.subsequent_indent(" ".into()),
);
detail_lines.extend(wrapped.iter().map(line_to_static));
}
}
}
}
Err(err) => {
let err_text = format_and_truncate_tool_result(
&format!("Error: {err}"),
TOOL_CALL_MAX_LINES,
width as usize,
);
let err_line = Line::from(err_text.dim());
let wrapped = adaptive_wrap_line(
&err_line,
RtOptions::new(detail_wrap_width)
.initial_indent("".into())
.subsequent_indent(" ".into()),
);
detail_lines.extend(wrapped.iter().map(line_to_static));
}
}
}
if !detail_lines.is_empty() {
let initial_prefix: Span<'static> = if inline_invocation {
"".dim()
} else {
" ".into()
};
lines.extend(prefix_lines(detail_lines, initial_prefix, " ".into()));
}
lines
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled || self.result.is_some() {
return None;
}
Some((self.start_time.elapsed().as_millis() / 50) as u64)
}
}
pub(crate) fn new_active_mcp_tool_call(
call_id: String,
invocation: McpInvocation,
animations_enabled: bool,
) -> McpToolCallCell {
McpToolCallCell::new(call_id, invocation, animations_enabled)
}
fn web_search_header(completed: bool) -> &'static str {
if completed {
"Searched"
} else {
"Searching the web"
}
}
#[derive(Debug)]
pub(crate) struct WebSearchCell {
call_id: String,
query: String,
action: Option<WebSearchAction>,
start_time: Instant,
completed: bool,
animations_enabled: bool,
}
impl WebSearchCell {
pub(crate) fn new(
call_id: String,
query: String,
action: Option<WebSearchAction>,
animations_enabled: bool,
) -> Self {
Self {
call_id,
query,
action,
start_time: Instant::now(),
completed: false,
animations_enabled,
}
}
pub(crate) fn call_id(&self) -> &str {
&self.call_id
}
pub(crate) fn update(&mut self, action: WebSearchAction, query: String) {
self.action = Some(action);
self.query = query;
}
pub(crate) fn complete(&mut self) {
self.completed = true;
}
}
impl HistoryCell for WebSearchCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let bullet = if self.completed {
"".dim()
} else {
spinner(Some(self.start_time), self.animations_enabled)
};
let header = web_search_header(self.completed);
let detail = web_search_detail(self.action.as_ref(), &self.query);
let text: Text<'static> = if detail.is_empty() {
Line::from(vec![header.bold()]).into()
} else {
Line::from(vec![header.bold(), " ".into(), detail.into()]).into()
};
PrefixedWrappedHistoryCell::new(text, vec![bullet, " ".into()], " ").display_lines(width)
}
}
pub(crate) fn new_active_web_search_call(
call_id: String,
query: String,
animations_enabled: bool,
) -> WebSearchCell {
WebSearchCell::new(call_id, query, None, animations_enabled)
}
pub(crate) fn new_web_search_call(
call_id: String,
query: String,
action: WebSearchAction,
) -> WebSearchCell {
let mut cell = WebSearchCell::new(call_id, query, Some(action), false);
cell.complete();
cell
}
/// Returns an additional history cell if an MCP tool result includes a decodable image.
///
/// This intentionally returns at most one cell: the first image in `CallToolResult.content` that
/// successfully base64-decodes and parses as an image. This is used as a lightweight “image output
/// exists” affordance separate from the main MCP tool call cell.
///
/// Manual testing tip:
/// - Run the rmcp stdio test server (`codex-rs/rmcp-client/src/bin/test_stdio_server.rs`) and
/// register it as an MCP server via `codex mcp add`.
/// - Use its `image_scenario` tool with cases like `text_then_image`,
/// `invalid_base64_then_image`, or `invalid_image_bytes_then_image` to ensure this path triggers
/// even when the first block is not a valid image.
fn try_new_completed_mcp_tool_call_with_image_output(
result: &Result<codex_protocol::mcp::CallToolResult, String>,
) -> Option<CompletedMcpToolCallWithImageOutput> {
let image = result
.as_ref()
.ok()?
.content
.iter()
.find_map(decode_mcp_image)?;
Some(CompletedMcpToolCallWithImageOutput { _image: image })
}
/// Decodes an MCP `ImageContent` block into an in-memory image.
///
/// Returns `None` when the block is not an image, when base64 decoding fails, when the format
/// cannot be inferred, or when the image decoder rejects the bytes.
fn decode_mcp_image(block: &serde_json::Value) -> Option<DynamicImage> {
let content = serde_json::from_value::<rmcp::model::Content>(block.clone()).ok()?;
let rmcp::model::RawContent::Image(image) = content.raw else {
return None;
};
let base64_data = if let Some(data_url) = image.data.strip_prefix("data:") {
data_url.split_once(',')?.1
} else {
image.data.as_str()
};
let raw_data = base64::engine::general_purpose::STANDARD
.decode(base64_data)
.map_err(|e| {
error!("Failed to decode image data: {e}");
e
})
.ok()?;
let reader = ImageReader::new(Cursor::new(raw_data))
.with_guessed_format()
.map_err(|e| {
error!("Failed to guess image format: {e}");
e
})
.ok()?;
reader
.decode()
.map_err(|e| {
error!("Image decoding failed: {e}");
e
})
.ok()
}
#[allow(clippy::disallowed_methods)]
pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
PrefixedWrappedHistoryCell::new(message.yellow(), "".yellow(), " ")
}
#[derive(Debug)]
pub(crate) struct DeprecationNoticeCell {
summary: String,
details: Option<String>,
}
pub(crate) fn new_deprecation_notice(
summary: String,
details: Option<String>,
) -> DeprecationNoticeCell {
DeprecationNoticeCell { summary, details }
}
impl HistoryCell for DeprecationNoticeCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(vec!["".red().bold(), self.summary.clone().red()].into());
let wrap_width = width.saturating_sub(4).max(1) as usize;
if let Some(details) = &self.details {
let detail_line = Line::from(details.clone().dim());
let wrapped = adaptive_wrap_line(&detail_line, RtOptions::new(wrap_width));
push_owned_lines(&wrapped, &mut lines);
}
lines
}
}
/// Render a summary of configured MCP servers from the current `Config`.
pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![
"/mcp".magenta().into(),
"".into(),
vec!["🔌 ".into(), "MCP Tools".bold()].into(),
"".into(),
" • No MCP servers configured.".italic().into(),
Line::from(vec![
" See the ".into(),
"\u{1b}]8;;https://developers.openai.com/codex/mcp\u{7}MCP docs\u{1b}]8;;\u{7}"
.underlined(),
" to configure them.".into(),
])
.style(Style::default().add_modifier(Modifier::DIM)),
];
PlainHistoryCell { lines }
}
/// Render MCP tools grouped by connection using the fully-qualified tool names.
pub(crate) fn new_mcp_tools_output(
config: &Config,
tools: HashMap<String, codex_protocol::mcp::Tool>,
resources: HashMap<String, Vec<Resource>>,
resource_templates: HashMap<String, Vec<ResourceTemplate>>,
auth_statuses: &HashMap<String, McpAuthStatus>,
) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = vec![
"/mcp".magenta().into(),
"".into(),
vec!["🔌 ".into(), "MCP Tools".bold()].into(),
"".into(),
];
if tools.is_empty() {
lines.push(" • No MCP tools available.".italic().into());
lines.push("".into());
}
let mut servers: Vec<_> = config.mcp_servers.iter().collect();
servers.sort_by(|(a, _), (b, _)| a.cmp(b));
for (server, cfg) in servers {
let prefix = format!("mcp__{server}__");
let mut names: Vec<String> = tools
.keys()
.filter(|k| k.starts_with(&prefix))
.map(|k| k[prefix.len()..].to_string())
.collect();
names.sort();
let auth_status = auth_statuses
.get(server.as_str())
.copied()
.unwrap_or(McpAuthStatus::Unsupported);
let mut header: Vec<Span<'static>> = vec!["".into(), server.clone().into()];
if !cfg.enabled {
header.push(" ".into());
header.push("(disabled)".red());
lines.push(header.into());
if let Some(reason) = cfg.disabled_reason.as_ref().map(ToString::to_string) {
lines.push(vec![" • Reason: ".into(), reason.dim()].into());
}
lines.push(Line::from(""));
continue;
}
lines.push(header.into());
lines.push(vec![" • Status: ".into(), "enabled".green()].into());
lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into());
match &cfg.transport {
McpServerTransportConfig::Stdio {
command,
args,
env,
env_vars,
cwd,
} => {
let args_suffix = if args.is_empty() {
String::new()
} else {
format!(" {}", args.join(" "))
};
let cmd_display = format!("{command}{args_suffix}");
lines.push(vec![" • Command: ".into(), cmd_display.into()].into());
if let Some(cwd) = cwd.as_ref() {
lines.push(vec![" • Cwd: ".into(), cwd.display().to_string().into()].into());
}
let env_display = format_env_display(env.as_ref(), env_vars);
if env_display != "-" {
lines.push(vec![" • Env: ".into(), env_display.into()].into());
}
}
McpServerTransportConfig::StreamableHttp {
url,
http_headers,
env_http_headers,
..
} => {
lines.push(vec![" • URL: ".into(), url.clone().into()].into());
if let Some(headers) = http_headers.as_ref()
&& !headers.is_empty()
{
let mut pairs: Vec<_> = headers.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let display = pairs
.into_iter()
.map(|(name, _)| format!("{name}=*****"))
.collect::<Vec<_>>()
.join(", ");
lines.push(vec![" • HTTP headers: ".into(), display.into()].into());
}
if let Some(headers) = env_http_headers.as_ref()
&& !headers.is_empty()
{
let mut pairs: Vec<_> = headers.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let display = pairs
.into_iter()
.map(|(name, var)| format!("{name}={var}"))
.collect::<Vec<_>>()
.join(", ");
lines.push(vec![" • Env HTTP headers: ".into(), display.into()].into());
}
}
}
if names.is_empty() {
lines.push(" • Tools: (none)".into());
} else {
lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into());
}
let server_resources: Vec<Resource> =
resources.get(server.as_str()).cloned().unwrap_or_default();
if server_resources.is_empty() {
lines.push(" • Resources: (none)".into());
} else {
let mut spans: Vec<Span<'static>> = vec![" • Resources: ".into()];
for (idx, resource) in server_resources.iter().enumerate() {
if idx > 0 {
spans.push(", ".into());
}
let label = resource.title.as_ref().unwrap_or(&resource.name);
spans.push(label.clone().into());
spans.push(" ".into());
spans.push(format!("({})", resource.uri).dim());
}
lines.push(spans.into());
}
let server_templates: Vec<ResourceTemplate> = resource_templates
.get(server.as_str())
.cloned()
.unwrap_or_default();
if server_templates.is_empty() {
lines.push(" • Resource templates: (none)".into());
} else {
let mut spans: Vec<Span<'static>> = vec![" • Resource templates: ".into()];
for (idx, template) in server_templates.iter().enumerate() {
if idx > 0 {
spans.push(", ".into());
}
let label = template.title.as_ref().unwrap_or(&template.name);
spans.push(label.clone().into());
spans.push(" ".into());
spans.push(format!("({})", template.uri_template).dim());
}
lines.push(spans.into());
}
lines.push(Line::from(""));
}
PlainHistoryCell { lines }
}
pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHistoryCell {
let mut line = vec!["".dim(), message.into()];
if let Some(hint) = hint {
line.push(" ".into());
line.push(hint.dark_gray());
}
let lines: Vec<Line<'static>> = vec![line.into()];
PlainHistoryCell { lines }
}
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
// Use a hair space (U+200A) to create a subtle, near-invisible separation
// before the text. VS16 is intentionally omitted to keep spacing tighter
// in terminals like Ghostty.
let lines: Vec<Line<'static>> = vec![vec![format!("{message}").red()].into()];
PlainHistoryCell { lines }
}
/// Renders a completed (or interrupted) request_user_input exchange in history.
#[derive(Debug)]
pub(crate) struct RequestUserInputResultCell {
pub(crate) questions: Vec<RequestUserInputQuestion>,
pub(crate) answers: HashMap<String, RequestUserInputAnswer>,
pub(crate) interrupted: bool,
}
impl HistoryCell for RequestUserInputResultCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let width = width.max(1) as usize;
let total = self.questions.len();
let answered = self
.questions
.iter()
.filter(|question| {
self.answers
.get(&question.id)
.is_some_and(|answer| !answer.answers.is_empty())
})
.count();
let unanswered = total.saturating_sub(answered);
let mut header = vec!["".dim(), " ".into(), "Questions".bold()];
header.push(format!(" {answered}/{total} answered").dim());
if self.interrupted {
header.push(" (interrupted)".cyan());
}
let mut lines: Vec<Line<'static>> = vec![header.into()];
for question in &self.questions {
let answer = self.answers.get(&question.id);
let answer_missing = match answer {
Some(answer) => answer.answers.is_empty(),
None => true,
};
let mut question_lines = wrap_with_prefix(
&question.question,
width,
"".into(),
" ".into(),
Style::default(),
);
if answer_missing && let Some(last) = question_lines.last_mut() {
last.spans.push(" (unanswered)".dim());
}
lines.extend(question_lines);
let Some(answer) = answer.filter(|answer| !answer.answers.is_empty()) else {
continue;
};
if question.is_secret {
lines.extend(wrap_with_prefix(
"••••••",
width,
" answer: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
));
continue;
}
let (options, note) = split_request_user_input_answer(answer);
for option in options {
lines.extend(wrap_with_prefix(
&option,
width,
" answer: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
));
}
if let Some(note) = note {
let (label, continuation, style) = if question.options.is_some() {
(
" note: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
)
} else {
(
" answer: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
)
};
lines.extend(wrap_with_prefix(&note, width, label, continuation, style));
}
}
if self.interrupted && unanswered > 0 {
let summary = format!("interrupted with {unanswered} unanswered");
lines.extend(wrap_with_prefix(
&summary,
width,
"".cyan().dim(),
" ".dim(),
Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
));
}
lines
}
}
/// Wrap a plain string with textwrap and prefix each line, while applying a style to the content.
fn wrap_with_prefix(
text: &str,
width: usize,
initial_prefix: Span<'static>,
subsequent_prefix: Span<'static>,
style: Style,
) -> Vec<Line<'static>> {
let line = Line::from(vec![Span::from(text.to_string()).set_style(style)]);
let opts = RtOptions::new(width.max(1))
.initial_indent(Line::from(vec![initial_prefix]))
.subsequent_indent(Line::from(vec![subsequent_prefix]));
let wrapped = adaptive_wrap_line(&line, opts);
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
}
/// Split a request_user_input answer into option labels and an optional freeform note.
/// Notes are encoded as "user_note: <text>" entries in the answers list.
fn split_request_user_input_answer(
answer: &RequestUserInputAnswer,
) -> (Vec<String>, Option<String>) {
let mut options = Vec::new();
let mut note = None;
for entry in &answer.answers {
if let Some(note_text) = entry.strip_prefix("user_note: ") {
note = Some(note_text.to_string());
} else {
options.push(entry.clone());
}
}
(options, note)
}
/// Render a userfriendly plan update styled like a checkbox todo list.
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
let UpdatePlanArgs { explanation, plan } = update;
PlanUpdateCell { explanation, plan }
}
pub(crate) fn new_proposed_plan(plan_markdown: String) -> ProposedPlanCell {
ProposedPlanCell { plan_markdown }
}
pub(crate) fn new_proposed_plan_stream(
lines: Vec<Line<'static>>,
is_stream_continuation: bool,
) -> ProposedPlanStreamCell {
ProposedPlanStreamCell {
lines,
is_stream_continuation,
}
}
#[derive(Debug)]
pub(crate) struct ProposedPlanCell {
plan_markdown: String,
}
#[derive(Debug)]
pub(crate) struct ProposedPlanStreamCell {
lines: Vec<Line<'static>>,
is_stream_continuation: bool,
}
impl HistoryCell for ProposedPlanCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(vec!["".dim(), "Proposed Plan".bold()].into());
lines.push(Line::from(" "));
let mut plan_lines: Vec<Line<'static>> = vec![Line::from(" ")];
let plan_style = proposed_plan_style();
let wrap_width = width.saturating_sub(4).max(1) as usize;
let mut body: Vec<Line<'static>> = Vec::new();
append_markdown(&self.plan_markdown, Some(wrap_width), &mut body);
if body.is_empty() {
body.push(Line::from("(empty)".dim().italic()));
}
plan_lines.extend(prefix_lines(body, " ".into(), " ".into()));
plan_lines.push(Line::from(" "));
lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style)));
lines
}
}
impl HistoryCell for ProposedPlanStreamCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
fn is_stream_continuation(&self) -> bool {
self.is_stream_continuation
}
}
#[derive(Debug)]
pub(crate) struct PlanUpdateCell {
explanation: Option<String>,
plan: Vec<PlanItemArg>,
}
impl HistoryCell for PlanUpdateCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let render_note = |text: &str| -> Vec<Line<'static>> {
let wrap_width = width.saturating_sub(4).max(1) as usize;
let note = Line::from(text.to_string().dim().italic());
let wrapped = adaptive_wrap_line(&note, RtOptions::new(wrap_width));
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
};
let render_step = |status: &StepStatus, text: &str| -> Vec<Line<'static>> {
let (box_str, step_style) = match status {
StepStatus::Completed => ("", Style::default().crossed_out().dim()),
StepStatus::InProgress => ("", Style::default().cyan().bold()),
StepStatus::Pending => ("", Style::default().dim()),
};
let opts = RtOptions::new(width.saturating_sub(4).max(1) as usize)
.initial_indent(box_str.into())
.subsequent_indent(" ".into());
let step = Line::from(text.to_string().set_style(step_style));
let wrapped = adaptive_wrap_line(&step, opts);
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
};
let mut lines: Vec<Line<'static>> = vec![];
lines.push(vec!["".dim(), "Updated Plan".bold()].into());
let mut indented_lines = vec![];
let note = self
.explanation
.as_ref()
.map(|s| s.trim())
.filter(|t| !t.is_empty());
if let Some(expl) = note {
indented_lines.extend(render_note(expl));
};
if self.plan.is_empty() {
indented_lines.push(Line::from("(no steps provided)".dim().italic()));
} else {
for PlanItemArg { step, status } in self.plan.iter() {
indented_lines.extend(render_step(status, step));
}
}
lines.extend(prefix_lines(indented_lines, "".dim(), " ".into()));
lines
}
}
/// Create a new `PendingPatch` cell that lists the filelevel summary of
/// a proposed patch. The summary lines should already be formatted (e.g.
/// "A path/to/file.rs").
pub(crate) fn new_patch_event(
changes: HashMap<PathBuf, FileChange>,
cwd: &Path,
) -> PatchHistoryCell {
PatchHistoryCell {
changes,
cwd: cwd.to_path_buf(),
}
}
pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
// Failure title
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
if !stderr.trim().is_empty() {
let output = output_lines(
Some(&CommandOutput {
exit_code: 1,
formatted_output: String::new(),
aggregated_output: stderr,
}),
OutputLinesParams {
line_limit: TOOL_CALL_MAX_LINES,
only_err: true,
include_angle_pipe: true,
include_prefix: true,
},
);
lines.extend(output.lines);
}
PlainHistoryCell { lines }
}
pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> PlainHistoryCell {
let display_path = display_path_for(&path, cwd);
let lines: Vec<Line<'static>> = vec![
vec!["".dim(), "Viewed Image".bold()].into(),
vec!["".dim(), display_path.dim()].into(),
];
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_summary_block(full_reasoning_buffer: String) -> Box<dyn HistoryCell> {
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();
return Box::new(ReasoningSummaryCell::new(
header_buffer,
summary_buffer,
false,
));
}
}
}
Box::new(ReasoningSummaryCell::new(
"".to_string(),
full_reasoning_buffer.to_string(),
true,
))
}
#[derive(Debug)]
/// A visual divider between turns, optionally showing how long the assistant "worked for".
///
/// This separator is only emitted for turns that performed concrete work (e.g., running commands,
/// applying patches, making MCP tool calls), so purely conversational turns do not show an empty
/// divider.
pub struct FinalMessageSeparator {
elapsed_seconds: Option<u64>,
runtime_metrics: Option<RuntimeMetricsSummary>,
}
impl FinalMessageSeparator {
/// Creates a separator; `elapsed_seconds` typically comes from the status indicator timer.
pub(crate) fn new(
elapsed_seconds: Option<u64>,
runtime_metrics: Option<RuntimeMetricsSummary>,
) -> Self {
Self {
elapsed_seconds,
runtime_metrics,
}
}
}
impl HistoryCell for FinalMessageSeparator {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut label_parts = Vec::new();
if let Some(elapsed_seconds) = self
.elapsed_seconds
.filter(|seconds| *seconds > 60)
.map(super::status_indicator_widget::fmt_elapsed_compact)
{
label_parts.push(format!("Worked for {elapsed_seconds}"));
}
if let Some(metrics_label) = self.runtime_metrics.and_then(runtime_metrics_label) {
label_parts.push(metrics_label);
}
if label_parts.is_empty() {
return vec![Line::from_iter(["".repeat(width as usize).dim()])];
}
let label = format!("{}", label_parts.join(""));
let (label, _suffix, label_width) = take_prefix_by_width(&label, width as usize);
vec![
Line::from_iter([
label,
"".repeat((width as usize).saturating_sub(label_width)),
])
.dim(),
]
}
}
pub(crate) fn runtime_metrics_label(summary: RuntimeMetricsSummary) -> Option<String> {
let mut parts = Vec::new();
if summary.tool_calls.count > 0 {
let duration = format_duration_ms(summary.tool_calls.duration_ms);
let calls = pluralize(summary.tool_calls.count, "call", "calls");
parts.push(format!(
"Local tools: {} {calls} ({duration})",
summary.tool_calls.count
));
}
if summary.api_calls.count > 0 {
let duration = format_duration_ms(summary.api_calls.duration_ms);
let calls = pluralize(summary.api_calls.count, "call", "calls");
parts.push(format!(
"Inference: {} {calls} ({duration})",
summary.api_calls.count
));
}
if summary.websocket_calls.count > 0 {
let duration = format_duration_ms(summary.websocket_calls.duration_ms);
parts.push(format!(
"WebSocket: {} events send ({duration})",
summary.websocket_calls.count
));
}
if summary.streaming_events.count > 0 {
let duration = format_duration_ms(summary.streaming_events.duration_ms);
let stream_label = pluralize(summary.streaming_events.count, "Stream", "Streams");
let events = pluralize(summary.streaming_events.count, "event", "events");
parts.push(format!(
"{stream_label}: {} {events} ({duration})",
summary.streaming_events.count
));
}
if summary.websocket_events.count > 0 {
let duration = format_duration_ms(summary.websocket_events.duration_ms);
parts.push(format!(
"{} events received ({duration})",
summary.websocket_events.count
));
}
if summary.responses_api_overhead_ms > 0 {
let duration = format_duration_ms(summary.responses_api_overhead_ms);
parts.push(format!("Responses API overhead: {duration}"));
}
if summary.responses_api_inference_time_ms > 0 {
let duration = format_duration_ms(summary.responses_api_inference_time_ms);
parts.push(format!("Responses API inference: {duration}"));
}
if summary.responses_api_engine_iapi_ttft_ms > 0
|| summary.responses_api_engine_service_ttft_ms > 0
{
let mut ttft_parts = Vec::new();
if summary.responses_api_engine_iapi_ttft_ms > 0 {
let duration = format_duration_ms(summary.responses_api_engine_iapi_ttft_ms);
ttft_parts.push(format!("{duration} (iapi)"));
}
if summary.responses_api_engine_service_ttft_ms > 0 {
let duration = format_duration_ms(summary.responses_api_engine_service_ttft_ms);
ttft_parts.push(format!("{duration} (service)"));
}
parts.push(format!("TTFT: {}", ttft_parts.join(" ")));
}
if summary.responses_api_engine_iapi_tbt_ms > 0
|| summary.responses_api_engine_service_tbt_ms > 0
{
let mut tbt_parts = Vec::new();
if summary.responses_api_engine_iapi_tbt_ms > 0 {
let duration = format_duration_ms(summary.responses_api_engine_iapi_tbt_ms);
tbt_parts.push(format!("{duration} (iapi)"));
}
if summary.responses_api_engine_service_tbt_ms > 0 {
let duration = format_duration_ms(summary.responses_api_engine_service_tbt_ms);
tbt_parts.push(format!("{duration} (service)"));
}
parts.push(format!("TBT: {}", tbt_parts.join(" ")));
}
if parts.is_empty() {
None
} else {
Some(parts.join(""))
}
}
fn format_duration_ms(duration_ms: u64) -> String {
if duration_ms >= 1_000 {
let seconds = duration_ms as f64 / 1_000.0;
format!("{seconds:.1}s")
} else {
format!("{duration_ms}ms")
}
}
fn pluralize(count: u64, singular: &'static str, plural: &'static str) -> &'static str {
if count == 1 { singular } else { plural }
}
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
let args_str = invocation
.arguments
.as_ref()
.map(|v: &serde_json::Value| {
// Use compact form to keep things short but readable.
serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
})
.unwrap_or_default();
let invocation_spans = vec![
invocation.server.clone().cyan(),
".".into(),
invocation.tool.cyan(),
"(".into(),
args_str.dim(),
")".into(),
];
invocation_spans.into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCall;
use crate::exec_cell::ExecCell;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::types::McpServerConfig;
use codex_core::config::types::McpServerTransportConfig;
use codex_otel::RuntimeMetricTotals;
use codex_otel::RuntimeMetricsSummary;
use codex_protocol::models::WebSearchAction;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::McpAuthStatus;
use dirs::home_dir;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashMap;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::mcp::Tool;
use codex_protocol::protocol::ExecCommandSource;
use rmcp::model::Content;
const SMALL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
async fn test_config() -> Config {
let codex_home = std::env::temp_dir();
ConfigBuilder::default()
.codex_home(codex_home.clone())
.build()
.await
.expect("config")
}
fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect()
}
fn render_transcript(cell: &dyn HistoryCell) -> Vec<String> {
render_lines(&cell.transcript_lines(u16::MAX))
}
fn image_block(data: &str) -> serde_json::Value {
serde_json::to_value(Content::image(data.to_string(), "image/png"))
.expect("image content should serialize")
}
fn text_block(text: &str) -> serde_json::Value {
serde_json::to_value(Content::text(text)).expect("text content should serialize")
}
fn resource_link_block(
uri: &str,
name: &str,
title: Option<&str>,
description: Option<&str>,
) -> serde_json::Value {
serde_json::to_value(Content::resource_link(rmcp::model::RawResource {
uri: uri.to_string(),
name: name.to_string(),
title: title.map(str::to_string),
description: description.map(str::to_string),
mime_type: None,
size: None,
icons: None,
meta: None,
}))
.expect("resource link content should serialize")
}
#[test]
fn unified_exec_interaction_cell_renders_input() {
let cell =
new_unified_exec_interaction(Some("echo hello".to_string()), "ls\npwd".to_string());
let lines = render_transcript(&cell);
assert_eq!(
lines,
vec![
"↳ Interacted with background terminal · echo hello",
" └ ls",
" pwd",
],
);
}
#[test]
fn unified_exec_interaction_cell_renders_wait() {
let cell = new_unified_exec_interaction(None, String::new());
let lines = render_transcript(&cell);
assert_eq!(lines, vec!["• Waited for background terminal"]);
}
#[test]
fn final_message_separator_hides_short_worked_label_and_includes_runtime_metrics() {
let summary = RuntimeMetricsSummary {
tool_calls: RuntimeMetricTotals {
count: 3,
duration_ms: 2_450,
},
api_calls: RuntimeMetricTotals {
count: 2,
duration_ms: 1_200,
},
streaming_events: RuntimeMetricTotals {
count: 6,
duration_ms: 900,
},
websocket_calls: RuntimeMetricTotals {
count: 1,
duration_ms: 700,
},
websocket_events: RuntimeMetricTotals {
count: 4,
duration_ms: 1_200,
},
responses_api_overhead_ms: 650,
responses_api_inference_time_ms: 1_940,
responses_api_engine_iapi_ttft_ms: 410,
responses_api_engine_service_ttft_ms: 460,
responses_api_engine_iapi_tbt_ms: 1_180,
responses_api_engine_service_tbt_ms: 1_240,
};
let cell = FinalMessageSeparator::new(Some(12), Some(summary));
let rendered = render_lines(&cell.display_lines(600));
assert_eq!(rendered.len(), 1);
assert!(!rendered[0].contains("Worked for"));
assert!(rendered[0].contains("Local tools: 3 calls (2.5s)"));
assert!(rendered[0].contains("Inference: 2 calls (1.2s)"));
assert!(rendered[0].contains("WebSocket: 1 events send (700ms)"));
assert!(rendered[0].contains("Streams: 6 events (900ms)"));
assert!(rendered[0].contains("4 events received (1.2s)"));
assert!(rendered[0].contains("Responses API overhead: 650ms"));
assert!(rendered[0].contains("Responses API inference: 1.9s"));
assert!(rendered[0].contains("TTFT: 410ms (iapi) 460ms (service)"));
assert!(rendered[0].contains("TBT: 1.2s (iapi) 1.2s (service)"));
}
#[test]
fn final_message_separator_includes_worked_label_after_one_minute() {
let cell = FinalMessageSeparator::new(Some(61), None);
let rendered = render_lines(&cell.display_lines(200));
assert_eq!(rendered.len(), 1);
assert!(rendered[0].contains("Worked for"));
}
#[test]
fn ps_output_empty_snapshot() {
let cell = new_unified_exec_processes_output(Vec::new());
let rendered = render_lines(&cell.display_lines(60)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn ps_output_multiline_snapshot() {
let cell = new_unified_exec_processes_output(vec![
UnifiedExecProcessDetails {
command_display: "echo hello\nand then some extra text".to_string(),
recent_chunks: vec!["hello".to_string(), "done".to_string()],
},
UnifiedExecProcessDetails {
command_display: "rg \"foo\" src".to_string(),
recent_chunks: vec!["src/main.rs:12:foo".to_string()],
},
]);
let rendered = render_lines(&cell.display_lines(40)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn ps_output_long_command_snapshot() {
let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails {
command_display: String::from(
"rg \"foo\" src --glob '**/*.rs' --max-count 1000 --no-ignore --hidden --follow --glob '!target/**'",
),
recent_chunks: vec!["searching...".to_string()],
}]);
let rendered = render_lines(&cell.display_lines(36)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn ps_output_many_sessions_snapshot() {
let cell = new_unified_exec_processes_output(
(0..20)
.map(|idx| UnifiedExecProcessDetails {
command_display: format!("command {idx}"),
recent_chunks: Vec::new(),
})
.collect(),
);
let rendered = render_lines(&cell.display_lines(32)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn ps_output_chunk_leading_whitespace_snapshot() {
let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails {
command_display: "just fix".to_string(),
recent_chunks: vec![
" indented first".to_string(),
" more indented".to_string(),
],
}]);
let rendered = render_lines(&cell.display_lines(60)).join("\n");
insta::assert_snapshot!(rendered);
}
#[tokio::test]
async fn mcp_tools_output_masks_sensitive_values() {
let mut config = test_config().await;
let mut env = HashMap::new();
env.insert("TOKEN".to_string(), "secret".to_string());
let stdio_config = McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: vec![],
env: Some(env),
env_vars: vec!["APP_TOKEN".to_string()],
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
};
let mut servers = config.mcp_servers.get().clone();
servers.insert("docs".to_string(), stdio_config);
let mut headers = HashMap::new();
headers.insert("Authorization".to_string(), "Bearer secret".to_string());
let mut env_headers = HashMap::new();
env_headers.insert("X-API-Key".to_string(), "API_KEY_ENV".to_string());
let http_config = McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
http_headers: Some(headers),
env_http_headers: Some(env_headers),
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
};
servers.insert("http".to_string(), http_config);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
let mut tools: HashMap<String, Tool> = HashMap::new();
tools.insert(
"mcp__docs__list".to_string(),
Tool {
description: None,
name: "list".to_string(),
title: None,
input_schema: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
);
tools.insert(
"mcp__http__ping".to_string(),
Tool {
description: None,
name: "ping".to_string(),
title: None,
input_schema: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
);
let auth_statuses: HashMap<String, McpAuthStatus> = HashMap::new();
let cell = new_mcp_tools_output(
&config,
tools,
HashMap::new(),
HashMap::new(),
&auth_statuses,
);
let rendered = render_lines(&cell.display_lines(120)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn empty_agent_message_cell_transcript() {
let cell = AgentMessageCell::new(vec![Line::default()], false);
assert_eq!(cell.transcript_lines(80), vec![Line::from(" ")]);
assert_eq!(cell.desired_transcript_height(80), 1);
}
#[test]
fn prefixed_wrapped_history_cell_indents_wrapped_lines() {
let summary = Line::from(vec![
"You ".into(),
"approved".bold(),
" codex to run ".into(),
"echo something really long to ensure wrapping happens".dim(),
" this time".bold(),
]);
let cell = PrefixedWrappedHistoryCell::new(summary, "".green(), " ");
let rendered = render_lines(&cell.display_lines(24));
assert_eq!(
rendered,
vec![
"✔ You approved codex to".to_string(),
" run echo something".to_string(),
" really long to ensure".to_string(),
" wrapping happens this".to_string(),
" time".to_string(),
]
);
}
#[test]
fn prefixed_wrapped_history_cell_does_not_split_url_like_token() {
let url_like =
"example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890";
let cell = PrefixedWrappedHistoryCell::new(Line::from(url_like), "".green(), " ");
let rendered = render_lines(&cell.display_lines(24));
assert_eq!(
rendered
.iter()
.filter(|line| line.contains(url_like))
.count(),
1,
"expected full URL-like token in one rendered line, got: {rendered:?}"
);
}
#[test]
fn unified_exec_interaction_cell_does_not_split_url_like_stdin_token() {
let url_like =
"example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890";
let cell = UnifiedExecInteractionCell::new(Some("true".to_string()), url_like.to_string());
let rendered = render_lines(&cell.display_lines(24));
assert_eq!(
rendered
.iter()
.filter(|line| line.contains(url_like))
.count(),
1,
"expected full URL-like token in one rendered line, got: {rendered:?}"
);
}
#[test]
fn prefixed_wrapped_history_cell_height_matches_wrapped_rendering() {
let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path";
let cell: Box<dyn HistoryCell> = Box::new(PrefixedWrappedHistoryCell::new(
Line::from(url_like),
"".green(),
" ",
));
let width: u16 = 24;
let logical_height = cell.display_lines(width).len() as u16;
let wrapped_height = cell.desired_height(width);
assert!(
wrapped_height > logical_height,
"expected wrapped height to exceed logical line count ({logical_height}), got {wrapped_height}"
);
let area = Rect::new(0, 0, width, wrapped_height);
let mut buf = ratatui::buffer::Buffer::empty(area);
cell.render(area, &mut buf);
let first_row = (0..area.width)
.map(|x| {
let symbol = buf[(x, 0)].symbol();
if symbol.is_empty() {
' '
} else {
symbol.chars().next().unwrap_or(' ')
}
})
.collect::<String>();
assert!(
first_row.contains(""),
"expected first rendered row to keep the prefix visible, got: {first_row:?}"
);
}
#[test]
fn unified_exec_interaction_cell_height_matches_wrapped_rendering() {
let url_like = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path";
let cell: Box<dyn HistoryCell> = Box::new(UnifiedExecInteractionCell::new(
Some("true".to_string()),
url_like.to_string(),
));
let width: u16 = 24;
let logical_height = cell.display_lines(width).len() as u16;
let wrapped_height = cell.desired_height(width);
assert!(
wrapped_height > logical_height,
"expected wrapped height to exceed logical line count ({logical_height}), got {wrapped_height}"
);
let area = Rect::new(0, 0, width, wrapped_height);
let mut buf = ratatui::buffer::Buffer::empty(area);
cell.render(area, &mut buf);
let first_row = (0..area.width)
.map(|x| {
let symbol = buf[(x, 0)].symbol();
if symbol.is_empty() {
' '
} else {
symbol.chars().next().unwrap_or(' ')
}
})
.collect::<String>();
assert!(
first_row.contains("Interacted with"),
"expected first rendered row to keep the header visible, got: {first_row:?}"
);
}
#[test]
fn web_search_history_cell_snapshot() {
let query =
"example search query with several generic words to exercise wrapping".to_string();
let cell = new_web_search_call(
"call-1".to_string(),
query.clone(),
WebSearchAction::Search {
query: Some(query),
queries: None,
},
);
let rendered = render_lines(&cell.display_lines(64)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn web_search_history_cell_wraps_with_indented_continuation() {
let query =
"example search query with several generic words to exercise wrapping".to_string();
let cell = new_web_search_call(
"call-1".to_string(),
query.clone(),
WebSearchAction::Search {
query: Some(query),
queries: None,
},
);
let rendered = render_lines(&cell.display_lines(64));
assert_eq!(
rendered,
vec![
"• Searched example search query with several generic words to".to_string(),
" exercise wrapping".to_string(),
]
);
}
#[test]
fn web_search_history_cell_short_query_does_not_wrap() {
let query = "short query".to_string();
let cell = new_web_search_call(
"call-1".to_string(),
query.clone(),
WebSearchAction::Search {
query: Some(query),
queries: None,
},
);
let rendered = render_lines(&cell.display_lines(64));
assert_eq!(rendered, vec!["• Searched short query".to_string()]);
}
#[test]
fn web_search_history_cell_transcript_snapshot() {
let query =
"example search query with several generic words to exercise wrapping".to_string();
let cell = new_web_search_call(
"call-1".to_string(),
query.clone(),
WebSearchAction::Search {
query: Some(query),
queries: None,
},
);
let rendered = render_lines(&cell.transcript_lines(64)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn active_mcp_tool_call_snapshot() {
let invocation = McpInvocation {
server: "search".into(),
tool: "find_docs".into(),
arguments: Some(json!({
"query": "ratatui styling",
"limit": 3,
})),
};
let cell = new_active_mcp_tool_call("call-1".into(), invocation, true);
let rendered = render_lines(&cell.display_lines(80)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn completed_mcp_tool_call_success_snapshot() {
let invocation = McpInvocation {
server: "search".into(),
tool: "find_docs".into(),
arguments: Some(json!({
"query": "ratatui styling",
"limit": 3,
})),
};
let result = CallToolResult {
content: vec![text_block("Found styling guidance in styles.md")],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-2".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(1420), Ok(result))
.is_none()
);
let rendered = render_lines(&cell.display_lines(80)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn completed_mcp_tool_call_image_after_text_returns_extra_cell() {
let invocation = McpInvocation {
server: "image".into(),
tool: "generate".into(),
arguments: Some(json!({
"prompt": "tiny image",
})),
};
let result = CallToolResult {
content: vec![
text_block("Here is the image:"),
image_block(SMALL_PNG_BASE64),
],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-image".into(), invocation, true);
let extra_cell = cell
.complete(Duration::from_millis(25), Ok(result))
.expect("expected image cell");
let rendered = render_lines(&extra_cell.display_lines(80));
assert_eq!(rendered, vec!["tool result (image output)"]);
}
#[test]
fn completed_mcp_tool_call_accepts_data_url_image_blocks() {
let invocation = McpInvocation {
server: "image".into(),
tool: "generate".into(),
arguments: Some(json!({
"prompt": "tiny image",
})),
};
let data_url = format!("data:image/png;base64,{SMALL_PNG_BASE64}");
let result = CallToolResult {
content: vec![image_block(&data_url)],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-image-data-url".into(), invocation, true);
let extra_cell = cell
.complete(Duration::from_millis(25), Ok(result))
.expect("expected image cell");
let rendered = render_lines(&extra_cell.display_lines(80));
assert_eq!(rendered, vec!["tool result (image output)"]);
}
#[test]
fn completed_mcp_tool_call_skips_invalid_image_blocks() {
let invocation = McpInvocation {
server: "image".into(),
tool: "generate".into(),
arguments: Some(json!({
"prompt": "tiny image",
})),
};
let result = CallToolResult {
content: vec![image_block("not-base64"), image_block(SMALL_PNG_BASE64)],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-image-2".into(), invocation, true);
let extra_cell = cell
.complete(Duration::from_millis(25), Ok(result))
.expect("expected image cell");
let rendered = render_lines(&extra_cell.display_lines(80));
assert_eq!(rendered, vec!["tool result (image output)"]);
}
#[test]
fn completed_mcp_tool_call_error_snapshot() {
let invocation = McpInvocation {
server: "search".into(),
tool: "find_docs".into(),
arguments: Some(json!({
"query": "ratatui styling",
"limit": 3,
})),
};
let mut cell = new_active_mcp_tool_call("call-3".into(), invocation, true);
assert!(
cell.complete(Duration::from_secs(2), Err("network timeout".into()))
.is_none()
);
let rendered = render_lines(&cell.display_lines(80)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn completed_mcp_tool_call_multiple_outputs_snapshot() {
let invocation = McpInvocation {
server: "search".into(),
tool: "find_docs".into(),
arguments: Some(json!({
"query": "ratatui styling",
"limit": 3,
})),
};
let result = CallToolResult {
content: vec![
text_block(
"Found styling guidance in styles.md and additional notes in CONTRIBUTING.md.",
),
resource_link_block(
"file:///docs/styles.md",
"styles.md",
Some("Styles"),
Some("Link to styles documentation"),
),
],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-4".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(640), Ok(result))
.is_none()
);
let rendered = render_lines(&cell.display_lines(48)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn completed_mcp_tool_call_wrapped_outputs_snapshot() {
let invocation = McpInvocation {
server: "metrics".into(),
tool: "get_nearby_metric".into(),
arguments: Some(json!({
"query": "very_long_query_that_needs_wrapping_to_display_properly_in_the_history",
"limit": 1,
})),
};
let result = CallToolResult {
content: vec![text_block(
"Line one of the response, which is quite long and needs wrapping.\nLine two continues the response with more detail.",
)],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-5".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(1280), Ok(result))
.is_none()
);
let rendered = render_lines(&cell.display_lines(40)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn completed_mcp_tool_call_multiple_outputs_inline_snapshot() {
let invocation = McpInvocation {
server: "metrics".into(),
tool: "summary".into(),
arguments: Some(json!({
"metric": "trace.latency",
"window": "15m",
})),
};
let result = CallToolResult {
content: vec![
text_block("Latency summary: p50=120ms, p95=480ms."),
text_block("No anomalies detected."),
],
is_error: None,
structured_content: None,
meta: None,
};
let mut cell = new_active_mcp_tool_call("call-6".into(), invocation, true);
assert!(
cell.complete(Duration::from_millis(320), Ok(result))
.is_none()
);
let rendered = render_lines(&cell.display_lines(120)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn session_header_includes_reasoning_level_when_present() {
let cell = SessionHeaderHistoryCell::new(
"gpt-4o".to_string(),
Some(ReasoningEffortConfig::High),
std::env::temp_dir(),
"test",
);
let lines = render_lines(&cell.display_lines(80));
let model_line = lines
.into_iter()
.find(|line| line.contains("model:"))
.expect("model line");
assert!(model_line.contains("gpt-4o high"));
assert!(model_line.contains("/model to change"));
}
#[test]
fn session_header_directory_center_truncates() {
let mut dir = home_dir().expect("home directory");
for part in ["hello", "the", "fox", "is", "very", "fast"] {
dir.push(part);
}
let formatted = SessionHeaderHistoryCell::format_directory_inner(&dir, Some(24));
let sep = std::path::MAIN_SEPARATOR;
let expected = format!("~{sep}hello{sep}the{sep}{sep}very{sep}fast");
assert_eq!(formatted, expected);
}
#[test]
fn session_header_directory_front_truncates_long_segment() {
let mut dir = home_dir().expect("home directory");
dir.push("supercalifragilisticexpialidocious");
let formatted = SessionHeaderHistoryCell::format_directory_inner(&dir, Some(18));
let sep = std::path::MAIN_SEPARATOR;
let expected = format!("~{sep}…cexpialidocious");
assert_eq!(formatted, expected);
}
#[test]
fn coalesces_sequential_reads_within_one_call() {
// Build one exec cell with a Search followed by two Reads
let call_id = "c1".to_string();
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![
ParsedCommand::Search {
query: Some("shimmer_spans".into()),
path: None,
cmd: "rg shimmer_spans".into(),
},
ParsedCommand::Read {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
},
ParsedCommand::Read {
name: "status_indicator_widget.rs".into(),
cmd: "cat status_indicator_widget.rs".into(),
path: "status_indicator_widget.rs".into(),
},
],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
// Mark call complete so markers are ✓
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn coalesces_reads_across_multiple_calls() {
let mut cell = ExecCell::new(
ExecCall {
call_id: "c1".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![ParsedCommand::Search {
query: Some("shimmer_spans".into()),
path: None,
cmd: "rg shimmer_spans".into(),
}],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
// Call 1: Search only
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
// Call 2: Read A
cell = cell
.with_added_call(
"c2".into(),
vec!["bash".into(), "-lc".into(), "echo".into()],
vec![ParsedCommand::Read {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
}],
ExecCommandSource::Agent,
None,
)
.unwrap();
cell.complete_call("c2", CommandOutput::default(), Duration::from_millis(1));
// Call 3: Read B
cell = cell
.with_added_call(
"c3".into(),
vec!["bash".into(), "-lc".into(), "echo".into()],
vec![ParsedCommand::Read {
name: "status_indicator_widget.rs".into(),
cmd: "cat status_indicator_widget.rs".into(),
path: "status_indicator_widget.rs".into(),
}],
ExecCommandSource::Agent,
None,
)
.unwrap();
cell.complete_call("c3", CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn coalesced_reads_dedupe_names() {
let mut cell = ExecCell::new(
ExecCall {
call_id: "c1".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo".into()],
parsed: vec![
ParsedCommand::Read {
name: "auth.rs".into(),
cmd: "cat auth.rs".into(),
path: "auth.rs".into(),
},
ParsedCommand::Read {
name: "auth.rs".into(),
cmd: "cat auth.rs".into(),
path: "auth.rs".into(),
},
ParsedCommand::Read {
name: "shimmer.rs".into(),
cmd: "cat shimmer.rs".into(),
path: "shimmer.rs".into(),
},
],
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn multiline_command_wraps_with_extra_indent_on_subsequent_lines() {
// Create a completed exec cell with a multiline command
let cmd = "set -o pipefail\ncargo test --all-features --quiet".to_string();
let call_id = "c1".to_string();
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
// Mark call complete so it renders as "Ran"
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
// Small width to force wrapping on both lines
let width: u16 = 28;
let lines = cell.display_lines(width);
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn single_line_command_compact_when_fits() {
let call_id = "c1".to_string();
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["echo".into(), "ok".into()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
// Wide enough that it fits inline
let lines = cell.display_lines(80);
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn single_line_command_wraps_with_four_space_continuation() {
let call_id = "c1".to_string();
let long = "a_very_long_token_without_spaces_to_force_wrapping".to_string();
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), long],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(24);
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn multiline_command_without_wrap_uses_branch_then_eight_spaces() {
let call_id = "c1".to_string();
let cmd = "echo one\necho two".to_string();
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(80);
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn multiline_command_both_lines_wrap_with_correct_prefixes() {
let call_id = "c1".to_string();
let cmd = "first_token_is_long_enough_to_wrap\nsecond_token_is_also_long_enough_to_wrap"
.to_string();
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), cmd],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
let lines = cell.display_lines(28);
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn stderr_tail_more_than_five_lines_snapshot() {
// Build an exec cell with a non-zero exit and 10 lines on stderr to exercise
// the head/tail rendering and gutter prefixes.
let call_id = "c_err".to_string();
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
let stderr: String = (1..=10)
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join("\n");
cell.complete_call(
&call_id,
CommandOutput {
exit_code: 1,
formatted_output: String::new(),
aggregated_output: stderr,
},
Duration::from_millis(1),
);
let rendered = cell
.display_lines(80)
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn ran_cell_multiline_with_stderr_snapshot() {
// Build an exec cell that completes (so it renders as "Ran") with a
// command long enough that it must render on its own line under the
// header, and include a couple of stderr lines to verify the output
// block prefixes and wrapping.
let call_id = "c_wrap_err".to_string();
let long_cmd =
"echo this_is_a_very_long_single_token_that_will_wrap_across_the_available_width";
let mut cell = ExecCell::new(
ExecCall {
call_id: call_id.clone(),
command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()],
parsed: Vec::new(),
output: None,
source: ExecCommandSource::Agent,
start_time: Some(Instant::now()),
duration: None,
interaction_input: None,
},
true,
);
let stderr = "error: first line on stderr\nerror: second line on stderr".to_string();
cell.complete_call(
&call_id,
CommandOutput {
exit_code: 1,
formatted_output: String::new(),
aggregated_output: stderr,
},
Duration::from_millis(5),
);
// Narrow width to force the command to render under the header line.
let width: u16 = 28;
let rendered = cell
.display_lines(width)
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn user_history_cell_wraps_and_prefixes_each_line_snapshot() {
let msg = "one two three four five six seven";
let cell = UserHistoryCell {
message: msg.to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
};
// Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌ prefix and trailing space.
let width: u16 = 12;
let lines = cell.display_lines(width);
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn user_history_cell_renders_remote_image_urls() {
let cell = UserHistoryCell {
message: "describe these".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec!["https://example.com/example.png".to_string()],
};
let rendered = render_lines(&cell.display_lines(80)).join("\n");
assert!(rendered.contains("[Image #1]"));
assert!(rendered.contains("describe these"));
insta::assert_snapshot!(rendered);
}
#[test]
fn user_history_cell_summarizes_inline_data_urls() {
let cell = UserHistoryCell {
message: "describe inline image".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec!["data:image/png;base64,aGVsbG8=".to_string()],
};
let rendered = render_lines(&cell.display_lines(80)).join("\n");
assert!(rendered.contains("[Image #1]"));
assert!(rendered.contains("describe inline image"));
}
#[test]
fn user_history_cell_numbers_multiple_remote_images() {
let cell = UserHistoryCell {
message: "describe both".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec![
"https://example.com/one.png".to_string(),
"https://example.com/two.png".to_string(),
],
};
let rendered = render_lines(&cell.display_lines(80)).join("\n");
assert!(rendered.contains("[Image #1]"));
assert!(rendered.contains("[Image #2]"));
insta::assert_snapshot!(rendered);
}
#[test]
fn user_history_cell_height_matches_rendered_lines_with_remote_images() {
let cell = UserHistoryCell {
message: "line one\nline two".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec![
"https://example.com/one.png".to_string(),
"https://example.com/two.png".to_string(),
],
};
let width = 80;
let rendered_len: u16 = cell
.display_lines(width)
.len()
.try_into()
.unwrap_or(u16::MAX);
assert_eq!(cell.desired_height(width), rendered_len);
assert_eq!(cell.desired_transcript_height(width), rendered_len);
}
#[test]
fn user_history_cell_trims_trailing_blank_message_lines() {
let cell = UserHistoryCell {
message: "line one\n\n \n\t \n".to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: vec!["https://example.com/one.png".to_string()],
};
let rendered = render_lines(&cell.display_lines(80));
let trailing_blank_count = rendered
.iter()
.rev()
.take_while(|line| line.trim().is_empty())
.count();
assert_eq!(trailing_blank_count, 1);
assert!(rendered.iter().any(|line| line.contains("line one")));
}
#[test]
fn user_history_cell_trims_trailing_blank_message_lines_with_text_elements() {
let message = "tokenized\n\n\n".to_string();
let cell = UserHistoryCell {
message,
text_elements: vec![TextElement::new(
(0..8).into(),
Some("tokenized".to_string()),
)],
local_image_paths: Vec::new(),
remote_image_urls: vec!["https://example.com/one.png".to_string()],
};
let rendered = render_lines(&cell.display_lines(80));
let trailing_blank_count = rendered
.iter()
.rev()
.take_while(|line| line.trim().is_empty())
.count();
assert_eq!(trailing_blank_count, 1);
assert!(rendered.iter().any(|line| line.contains("tokenized")));
}
#[test]
fn render_uses_wrapping_for_long_url_like_line() {
let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/that/keeps/going/for/testing/purposes-only-and-does/not/need/to/resolve/index.html?session_id=abc123def456ghi789jkl012mno345pqr678stu901vwx234yz";
let cell: Box<dyn HistoryCell> = Box::new(UserHistoryCell {
message: url.to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
});
let width: u16 = 52;
let height = cell.desired_height(width);
assert!(
height > 1,
"expected wrapped height for long URL, got {height}"
);
let area = Rect::new(0, 0, width, height);
let mut buf = ratatui::buffer::Buffer::empty(area);
cell.render(area, &mut buf);
let rendered = (0..area.height)
.map(|y| {
(0..area.width)
.map(|x| {
let symbol = buf[(x, y)].symbol();
if symbol.is_empty() {
' '
} else {
symbol.chars().next().unwrap_or(' ')
}
})
.collect::<String>()
})
.collect::<Vec<_>>();
let rendered_blob = rendered.join("\n");
assert!(
rendered_blob.contains("session_id=abc123"),
"expected URL tail to be visible after wrapping, got:\n{rendered_blob}"
);
let non_empty_rows = rendered.iter().filter(|row| !row.trim().is_empty()).count() as u16;
assert!(
non_empty_rows > 3,
"expected long URL to span multiple visible rows, got:\n{rendered_blob}"
);
}
#[test]
fn plan_update_with_note_and_wrapping_snapshot() {
// Long explanation forces wrapping; include long step text to verify step wrapping and alignment.
let update = UpdatePlanArgs {
explanation: Some(
"Ill update Grafana call error handling by adding retries and clearer messages when the backend is unreachable."
.to_string(),
),
plan: vec![
PlanItemArg {
step: "Investigate existing error paths and logging around HTTP timeouts".into(),
status: StepStatus::Completed,
},
PlanItemArg {
step: "Harden Grafana client error handling with retry/backoff and userfriendly messages".into(),
status: StepStatus::InProgress,
},
PlanItemArg {
step: "Add tests for transient failure scenarios and surfacing to the UI".into(),
status: StepStatus::Pending,
},
],
};
let cell = new_plan_update(update);
// Narrow width to force wrapping for both the note and steps
let lines = cell.display_lines(32);
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn plan_update_without_note_snapshot() {
let update = UpdatePlanArgs {
explanation: None,
plan: vec![
PlanItemArg {
step: "Define error taxonomy".into(),
status: StepStatus::InProgress,
},
PlanItemArg {
step: "Implement mapping to user messages".into(),
status: StepStatus::Pending,
},
],
};
let cell = new_plan_update(update);
let lines = cell.display_lines(40);
let rendered = render_lines(&lines).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn plan_update_does_not_split_url_like_tokens_in_note_or_step() {
let note_url =
"example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890";
let step_url = "example.test/api/v1/projects/beta-team/releases/2026-02-17/builds/0987654321/artifacts/reports/performance";
let update = UpdatePlanArgs {
explanation: Some(format!(
"Investigate failures under {note_url} immediately."
)),
plan: vec![PlanItemArg {
step: format!("Validate callbacks under {step_url} before rollout."),
status: StepStatus::InProgress,
}],
};
let cell = new_plan_update(update);
let rendered = render_lines(&cell.display_lines(30));
assert_eq!(
rendered
.iter()
.filter(|line| line.contains(note_url))
.count(),
1,
"expected full note URL-like token in one rendered line, got: {rendered:?}"
);
assert_eq!(
rendered
.iter()
.filter(|line| line.contains(step_url))
.count(),
1,
"expected full step URL-like token in one rendered line, got: {rendered:?}"
);
}
#[test]
fn reasoning_summary_block() {
let cell = new_reasoning_summary_block(
"**High level reasoning**\n\nDetailed reasoning goes here.".to_string(),
);
let rendered_display = render_lines(&cell.display_lines(80));
assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]);
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(rendered_transcript, vec!["• Detailed reasoning goes here."]);
}
#[test]
fn reasoning_summary_height_matches_wrapped_rendering_for_url_like_content() {
let summary = "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/that/keeps/going";
let cell: Box<dyn HistoryCell> = Box::new(ReasoningSummaryCell::new(
"High level reasoning".to_string(),
summary.to_string(),
false,
));
let width: u16 = 24;
let logical_height = cell.display_lines(width).len() as u16;
let wrapped_height = cell.desired_height(width);
let expected_wrapped_height = Paragraph::new(Text::from(cell.display_lines(width)))
.wrap(Wrap { trim: false })
.line_count(width) as u16;
assert_eq!(wrapped_height, expected_wrapped_height);
assert!(
wrapped_height >= logical_height,
"expected wrapped height to be at least logical line count ({logical_height}), got {wrapped_height}"
);
let wrapped_transcript_height = cell.desired_transcript_height(width);
assert_eq!(wrapped_transcript_height, wrapped_height);
let area = Rect::new(0, 0, width, wrapped_height);
let mut buf = ratatui::buffer::Buffer::empty(area);
cell.render(area, &mut buf);
let first_row = (0..area.width)
.map(|x| {
let symbol = buf[(x, 0)].symbol();
if symbol.is_empty() {
' '
} else {
symbol.chars().next().unwrap_or(' ')
}
})
.collect::<String>();
assert!(
first_row.contains(""),
"expected first rendered row to keep summary bullet visible, got: {first_row:?}"
);
}
#[test]
fn reasoning_summary_block_returns_reasoning_cell_when_feature_disabled() {
let cell = new_reasoning_summary_block("Detailed reasoning goes here.".to_string());
let rendered = render_transcript(cell.as_ref());
assert_eq!(rendered, vec!["• Detailed reasoning goes here."]);
}
#[tokio::test]
async fn reasoning_summary_block_respects_config_overrides() {
let mut config = test_config().await;
config.model = Some("gpt-3.5-turbo".to_string());
config.model_supports_reasoning_summaries = Some(true);
let cell = new_reasoning_summary_block(
"**High level reasoning**\n\nDetailed reasoning goes here.".to_string(),
);
let rendered_display = render_lines(&cell.display_lines(80));
assert_eq!(rendered_display, vec!["• Detailed reasoning goes here."]);
}
#[test]
fn reasoning_summary_block_falls_back_when_header_is_missing() {
let cell =
new_reasoning_summary_block("**High level reasoning without closing".to_string());
let rendered = render_transcript(cell.as_ref());
assert_eq!(rendered, vec!["• **High level reasoning without closing"]);
}
#[test]
fn reasoning_summary_block_falls_back_when_summary_is_missing() {
let cell =
new_reasoning_summary_block("**High level reasoning without closing**".to_string());
let rendered = render_transcript(cell.as_ref());
assert_eq!(rendered, vec!["• High level reasoning without closing"]);
let cell = new_reasoning_summary_block(
"**High level reasoning without closing**\n\n ".to_string(),
);
let rendered = render_transcript(cell.as_ref());
assert_eq!(rendered, vec!["• High level reasoning without closing"]);
}
#[test]
fn reasoning_summary_block_splits_header_and_summary_when_present() {
let cell = new_reasoning_summary_block(
"**High level plan**\n\nWe should fix the bug next.".to_string(),
);
let rendered_display = render_lines(&cell.display_lines(80));
assert_eq!(rendered_display, vec!["• We should fix the bug next."]);
let rendered_transcript = render_transcript(cell.as_ref());
assert_eq!(rendered_transcript, vec!["• We should fix the bug next."]);
}
#[test]
fn deprecation_notice_renders_summary_with_details() {
let cell = new_deprecation_notice(
"Feature flag `foo`".to_string(),
Some("Use flag `bar` instead.".to_string()),
);
let lines = cell.display_lines(80);
let rendered = render_lines(&lines);
assert_eq!(
rendered,
vec![
"⚠ Feature flag `foo`".to_string(),
"Use flag `bar` instead.".to_string(),
]
);
}
}