mirror of
https://github.com/openai/codex.git
synced 2026-04-29 00:55:38 +00:00
3947 lines
135 KiB
Rust
3947 lines
135 KiB
Rust
//! 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(¬e, 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 user‑friendly 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(¬e, 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 file‑level 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(
|
||
"I’ll 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 user‑friendly 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(),
|
||
]
|
||
);
|
||
}
|
||
}
|