TUI: split history cells into focused modules (#22704)

## Why

`codex-rs/tui/src/history_cell.rs` had become the dumping ground for
transcript rendering: the shared trait, common helpers, and the concrete
cells for messages, plans, MCP/search, notices, patches, approvals,
session chrome, and separators all lived together. That made small
transcript changes require reopening a very large file and made
ownership less obvious.

## What changed

- Replaced the monolithic `history_cell.rs` with a `history_cell/`
module tree organized by concern.
- Kept the existing `crate::history_cell::*` surface stable through
re-exports in `history_cell/mod.rs`.
- Moved the existing render coverage into `history_cell/tests.rs`.

## Reviewer notes

- This PR is intentionally mechanical in mature — existing code and
tests moving into files that match their concern.
- The snapshot files under `codex-rs/tui/src/history_cell/snapshots/`
moved with the extracted test module. `insta` resolves these unnamed
snapshots relative to the source file that declares them, so this is
path churn only; snapshot contents were not updated.
- The small non-mechanical seam edits are limited to split fallout:
sibling-module visibility for shared cell containers, moving
approval-specific exec-snippet helpers beside approvals, fixing the
separator module path, and keeping a couple of existing test helpers
reachable after extraction.
This commit is contained in:
Eric Traut
2026-05-14 21:19:06 -07:00
committed by GitHub
Unverified
parent d1235a0a78
commit e6a7368810
54 changed files with 6168 additions and 6135 deletions
File diff suppressed because it is too large Load Diff
+360
View File
@@ -0,0 +1,360 @@
//! Approval, denial, and review-status transcript cells.
use super::*;
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, /*max_graphemes*/ 80);
snippet
}
fn exec_snippet(command: &[String]) -> String {
let full_cmd = strip_bash_lc_and_escape(command);
truncate_exec_snippet(&full_cmd)
}
fn non_empty_exec_snippet(command: &[String]) -> Option<String> {
let snippet = exec_snippet(command);
(!snippet.is_empty()).then_some(snippet)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ReviewDecision {
Approved,
ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: ExecPolicyAmendment,
},
ApprovedForSession,
NetworkPolicyAmendment {
network_policy_amendment: NetworkPolicyAmendment,
},
Denied,
TimedOut,
Abort,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ApprovalDecisionSubject {
Command(Vec<String>),
NetworkAccess { target: String },
}
pub fn new_approval_decision_cell(
subject: ApprovalDecisionSubject,
decision: ReviewDecision,
actor: ApprovalDecisionActor,
) -> Box<dyn HistoryCell> {
use ReviewDecision::*;
use codex_protocol::approvals::NetworkPolicyRuleAction;
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
Approved => match subject {
ApprovalDecisionSubject::Command(command) => {
let summary = if let Some(snippet) = non_empty_exec_snippet(&command) {
vec![
actor.subject().into(),
"approved".bold(),
" codex to run ".into(),
Span::from(snippet).dim(),
" this time".bold(),
]
} else {
vec![
actor.subject().into(),
"approved".bold(),
" this request".into(),
" this time".bold(),
]
};
("".green(), summary)
}
ApprovalDecisionSubject::NetworkAccess { target } => (
"".green(),
vec![
actor.subject().into(),
"approved".bold(),
" codex network access to ".into(),
Span::from(target).dim(),
" this time".bold(),
],
),
},
ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment,
} => {
let snippet = Span::from(exec_snippet(&proposed_execpolicy_amendment.command)).dim();
(
"".green(),
vec![
actor.subject().into(),
"approved".bold(),
" codex to always run commands that start with ".into(),
snippet,
],
)
}
ApprovedForSession => match subject {
ApprovalDecisionSubject::Command(command) => {
let summary = if let Some(snippet) = non_empty_exec_snippet(&command) {
vec![
actor.subject().into(),
"approved".bold(),
" codex to run ".into(),
Span::from(snippet).dim(),
" every time this session".bold(),
]
} else {
vec![
actor.subject().into(),
"approved".bold(),
" this request".into(),
" every time this session".bold(),
]
};
("".green(), summary)
}
ApprovalDecisionSubject::NetworkAccess { target } => (
"".green(),
vec![
actor.subject().into(),
"approved".bold(),
" codex network access to ".into(),
Span::from(target).dim(),
" every time this session".bold(),
],
),
},
NetworkPolicyAmendment {
network_policy_amendment,
} => {
let target = match subject {
ApprovalDecisionSubject::NetworkAccess { target } => target,
ApprovalDecisionSubject::Command(_) => network_policy_amendment.host,
};
match network_policy_amendment.action {
NetworkPolicyRuleAction::Allow => (
"".green(),
vec![
actor.subject().into(),
"persisted".bold(),
" Codex network access to ".into(),
Span::from(target).dim(),
],
),
NetworkPolicyRuleAction::Deny => (
"".red(),
vec![
actor.subject().into(),
"denied".bold(),
" codex network access to ".into(),
Span::from(target).dim(),
" and saved that rule".into(),
],
),
}
}
Denied => match subject {
ApprovalDecisionSubject::Command(command) => {
let summary = if let Some(snippet) = non_empty_exec_snippet(&command) {
let snippet = Span::from(snippet).dim();
match actor {
ApprovalDecisionActor::User => vec![
actor.subject().into(),
"did not approve".bold(),
" codex to run ".into(),
snippet,
],
ApprovalDecisionActor::Guardian => vec![
"Request ".into(),
"denied".bold(),
" for codex to run ".into(),
snippet,
],
}
} else {
match actor {
ApprovalDecisionActor::User => vec![
actor.subject().into(),
"did not approve".bold(),
" this request".into(),
],
ApprovalDecisionActor::Guardian => {
vec!["Request ".into(), "denied".bold()]
}
}
};
("".red(), summary)
}
ApprovalDecisionSubject::NetworkAccess { target } => (
"".red(),
vec![
actor.subject().into(),
"did not approve".bold(),
" codex network access to ".into(),
Span::from(target).dim(),
],
),
},
TimedOut => match subject {
ApprovalDecisionSubject::Command(command) => {
let summary = if let Some(snippet) = non_empty_exec_snippet(&command) {
vec![
"Review ".into(),
"timed out".bold(),
" before codex could run ".into(),
Span::from(snippet).dim(),
]
} else {
vec![
"Review ".into(),
"timed out".bold(),
" before this request could be approved".into(),
]
};
("".red(), summary)
}
ApprovalDecisionSubject::NetworkAccess { target } => (
"".red(),
vec![
"Review ".into(),
"timed out".bold(),
" before codex could access ".into(),
Span::from(target).dim(),
],
),
},
Abort => match subject {
ApprovalDecisionSubject::Command(command) => {
let summary = if let Some(snippet) = non_empty_exec_snippet(&command) {
vec![
actor.subject().into(),
"canceled".bold(),
" the request to run ".into(),
Span::from(snippet).dim(),
]
} else {
vec![
actor.subject().into(),
"canceled".bold(),
" this request".into(),
]
};
("".red(), summary)
}
ApprovalDecisionSubject::NetworkAccess { target } => (
"".red(),
vec![
actor.subject().into(),
"canceled".bold(),
" the request for codex network access to ".into(),
Span::from(target).dim(),
],
),
},
};
Box::new(PrefixedWrappedHistoryCell::new(
Line::from(summary),
symbol,
" ",
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalDecisionActor {
User,
Guardian,
}
impl ApprovalDecisionActor {
fn subject(self) -> &'static str {
match self {
Self::User => "You ",
Self::Guardian => "Auto-reviewer ",
}
}
}
pub fn new_guardian_denied_patch_request(files: Vec<String>) -> Box<dyn HistoryCell> {
let mut summary = vec![
"Request ".into(),
"denied".bold(),
" for codex to apply ".into(),
];
if files.len() == 1 {
summary.push("a patch touching ".into());
summary.push(Span::from(files[0].clone()).dim());
} else {
summary.push("a patch touching ".into());
summary.push(Span::from(files.len().to_string()).dim());
summary.push(" files".into());
}
Box::new(PrefixedWrappedHistoryCell::new(
Line::from(summary),
"".red(),
" ",
))
}
pub fn new_guardian_denied_action_request(summary: String) -> Box<dyn HistoryCell> {
let line = Line::from(vec![
"Request ".into(),
"denied".bold(),
" for ".into(),
Span::from(summary).dim(),
]);
Box::new(PrefixedWrappedHistoryCell::new(line, "".red(), " "))
}
pub fn new_guardian_approved_action_request(summary: String) -> Box<dyn HistoryCell> {
let line = Line::from(vec![
"Request ".into(),
"approved".bold(),
" for ".into(),
Span::from(summary).dim(),
]);
Box::new(PrefixedWrappedHistoryCell::new(line, "".green(), " "))
}
pub fn new_guardian_timed_out_patch_request(files: Vec<String>) -> Box<dyn HistoryCell> {
let mut summary = vec![
"Review ".into(),
"timed out".bold(),
" before codex could apply ".into(),
];
if files.len() == 1 {
summary.push("a patch touching ".into());
summary.push(Span::from(files[0].clone()).dim());
} else {
summary.push("a patch touching ".into());
summary.push(Span::from(files.len().to_string()).dim());
summary.push(" files".into());
}
Box::new(PrefixedWrappedHistoryCell::new(
Line::from(summary),
"".red(),
" ",
))
}
pub fn new_guardian_timed_out_action_request(summary: String) -> Box<dyn HistoryCell> {
let line = Line::from(vec![
"Review ".into(),
"timed out".bold(),
" before ".into(),
Span::from(summary).dim(),
]);
Box::new(PrefixedWrappedHistoryCell::new(line, "".red(), " "))
}
/// 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())],
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Shared history-cell building blocks reused across transcript concerns.
use super::*;
#[derive(Debug)]
pub(crate) struct PlainHistoryCell {
pub(super) 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()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.lines.clone())
}
}
#[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)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.text.clone().lines)
}
}
#[derive(Debug)]
pub(crate) struct CompositeHistoryCell {
pub(super) 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
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
let mut first = true;
for part in &self.parts {
let mut lines = part.raw_lines();
if !lines.is_empty() {
if !first {
out.push(Line::from(""));
}
out.append(&mut lines);
first = false;
}
}
out
}
}
+242
View File
@@ -0,0 +1,242 @@
//! Background terminal interaction and process-summary history cells.
use super::*;
#[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
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let mut out = Vec::new();
if self.stdin.is_empty() {
if let Some(command) = self
.command_display
.as_ref()
.filter(|command| !command.is_empty())
{
out.push(Line::from(format!(
"Waited for background terminal: {command}"
)));
} else {
out.push(Line::from("Waited for background terminal"));
}
return out;
}
if let Some(command) = self
.command_display
.as_ref()
.filter(|command| !command.is_empty())
{
out.push(Line::from(format!(
"Interacted with background terminal: {command}"
)));
} else {
out.push(Line::from("Interacted with background terminal"));
}
out.extend(raw_lines_from_source(&self.stdin));
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 raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
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)])
}
+780
View File
@@ -0,0 +1,780 @@
//! MCP tool-call, inventory, and output history cells.
use super::*;
#[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()]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![Line::from("tool result (image output)")]
}
}
fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str {
match status {
McpAuthStatus::Unsupported => "Unsupported",
McpAuthStatus::NotLoggedIn => "Not logged in",
McpAuthStatus::BearerToken => "Bearer token",
McpAuthStatus::OAuth => "OAuth",
}
}
#[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,
}
#[derive(Debug, Clone)]
pub(crate) struct McpInvocation {
pub(crate) server: String,
pub(crate) tool: String,
pub(crate) arguments: Option<serde_json::Value>,
}
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 => activity_indicator(
Some(self.start_time),
MotionMode::from_animations_enabled(self.animations_enabled),
ReducedMotionIndicator::StaticBullet,
)
.unwrap_or_else(|| "".dim()),
};
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 raw_lines(&self) -> Vec<Line<'static>> {
let header_text = if self.success().is_some() {
"Called"
} else {
"Calling"
};
let mut lines = vec![Line::from(format!(
"{header_text} {}",
format_mcp_invocation(self.invocation.clone())
))];
if let Some(result) = &self.result {
match result {
Ok(codex_protocol::mcp::CallToolResult { content, .. }) => {
for block in content {
let text = Self::render_content_block(block, RAW_TOOL_OUTPUT_WIDTH);
lines.extend(raw_lines_from_source(&text));
}
}
Err(err) => lines.push(Line::from(format!("Error: {err}"))),
}
}
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)
}
/// 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()
}
/// 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 }
}
#[cfg(test)]
/// 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 effective_servers = config.mcp_servers.get().clone();
let mut servers: Vec<_> = effective_servers.iter().collect();
servers.sort_by(|(a, _), (b, _)| a.cmp(b));
for (server, cfg) in servers {
let prefix = qualified_mcp_tool_name_prefix(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(),
mcp_auth_status_label(auth_status).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 }
}
/// Build the `/mcp` history cell from app-server `McpServerStatus` responses.
///
/// The server list comes directly from the app-server status response, sorted
/// alphabetically. Local config is only used to enrich returned servers with
/// transport details such as command, URL, cwd, and environment display.
///
/// This mirrors the layout of [`new_mcp_tools_output`] but sources data from
/// the paginated RPC response rather than the in-process `McpManager`. The
/// `detail` flag controls whether resources and resource templates are rendered.
pub(crate) fn new_mcp_tools_output_from_statuses(
config: &Config,
statuses: &[McpServerStatus],
detail: McpServerStatusDetail,
) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = vec![
"/mcp".magenta().into(),
"".into(),
vec!["🔌 ".into(), "MCP Tools".bold()].into(),
"".into(),
];
let mut statuses_by_name = HashMap::new();
for status in statuses {
statuses_by_name.insert(status.name.as_str(), status);
}
let mut server_names: Vec<String> = statuses.iter().map(|status| status.name.clone()).collect();
server_names.sort();
let has_any_tools = statuses.iter().any(|status| !status.tools.is_empty());
if !has_any_tools {
lines.push(" • No MCP tools available.".italic().into());
lines.push("".into());
}
for server in server_names {
let cfg = config.mcp_servers.get().get(server.as_str());
let status = statuses_by_name.get(server.as_str()).copied();
let header: Vec<Span<'static>> = vec!["".into(), server.clone().into()];
lines.push(header.into());
if matches!(detail, McpServerStatusDetail::Full) {
let enabled = cfg.map(|cfg| cfg.enabled).unwrap_or(true);
let status_text = if enabled {
"enabled".green()
} else {
"disabled".red()
};
lines.push(vec![" • Status: ".into(), status_text].into());
if let Some(reason) = cfg.and_then(|cfg| cfg.disabled_reason.as_ref()) {
lines.push(vec![" • Reason: ".into(), reason.to_string().dim()].into());
}
}
let auth_status = status
.map(|status| match status.auth_status {
codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported,
codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn,
codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken,
codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth,
})
.unwrap_or(McpAuthStatus::Unsupported);
lines.push(
vec![
" • Auth: ".into(),
mcp_auth_status_label(auth_status).into(),
]
.into(),
);
if let Some(cfg) = cfg {
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.as_slice());
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());
}
}
}
}
let mut names = status
.map(|status| status.tools.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
names.sort();
if names.is_empty() {
lines.push(" • Tools: (none)".into());
} else {
lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into());
}
if matches!(detail, McpServerStatusDetail::Full) {
let server_resources = status
.map(|status| status.resources.clone())
.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 = status
.map(|status| status.resource_templates.clone())
.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 }
}
/// A transient history cell that shows an animated spinner while the MCP
/// inventory RPC is in flight.
///
/// Inserted as the `active_cell` by `ChatWidget::add_mcp_output()` and removed
/// once the fetch completes. The app removes committed copies from transcript
/// history, while `ChatWidget::clear_mcp_inventory_loading()` only clears the
/// in-flight `active_cell`.
#[derive(Debug)]
pub(crate) struct McpInventoryLoadingCell {
start_time: Instant,
animations_enabled: bool,
}
impl McpInventoryLoadingCell {
pub(crate) fn new(animations_enabled: bool) -> Self {
Self {
start_time: Instant::now(),
animations_enabled,
}
}
}
impl HistoryCell for McpInventoryLoadingCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![
vec![
activity_indicator(
Some(self.start_time),
MotionMode::from_animations_enabled(self.animations_enabled),
ReducedMotionIndicator::StaticBullet,
)
.unwrap_or_else(|| "".dim()),
" ".into(),
"Loading MCP inventory".bold(),
"".dim(),
]
.into(),
]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![Line::from("Loading MCP inventory...")]
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled {
return None;
}
Some((self.start_time.elapsed().as_millis() / 50) as u64)
}
}
/// Convenience constructor for [`McpInventoryLoadingCell`].
pub(crate) fn new_mcp_inventory_loading(animations_enabled: bool) -> McpInventoryLoadingCell {
McpInventoryLoadingCell::new(animations_enabled)
}
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()
}
+455
View File
@@ -0,0 +1,455 @@
//! User, assistant, reasoning, and streaming message history cells.
use super::*;
#[derive(Debug)]
pub(crate) struct UserHistoryCell {
pub message: String,
pub text_elements: Vec<TextElement>,
#[allow(dead_code)]
pub local_image_paths: Vec<PathBuf>,
pub remote_image_urls: Vec<String>,
}
/// Build logical lines for a user message with styled text elements.
///
/// This preserves explicit newlines while interleaving element spans and skips
/// malformed byte ranges instead of panicking during history rendering.
fn build_user_message_lines_with_elements(
message: &str,
elements: &[TextElement],
style: Style,
element_style: Style,
) -> Vec<Line<'static>> {
let mut elements = elements.to_vec();
elements.sort_by_key(|e| e.byte_range.start);
let mut offset = 0usize;
let mut raw_lines: Vec<Line<'static>> = Vec::new();
for line_text in message.split('\n') {
let line_start = offset;
let line_end = line_start + line_text.len();
let mut spans: Vec<Span<'static>> = Vec::new();
// Track how much of the line we've emitted to interleave plain and styled spans.
let mut cursor = line_start;
for elem in &elements {
let start = elem.byte_range.start.max(line_start);
let end = elem.byte_range.end.min(line_end);
if start >= end {
continue;
}
let rel_start = start - line_start;
let rel_end = end - line_start;
// Guard against malformed UTF-8 byte ranges from upstream data; skip
// invalid elements rather than panicking while rendering history.
if !line_text.is_char_boundary(rel_start) || !line_text.is_char_boundary(rel_end) {
continue;
}
let rel_cursor = cursor - line_start;
if cursor < start
&& line_text.is_char_boundary(rel_cursor)
&& let Some(segment) = line_text.get(rel_cursor..rel_start)
{
spans.push(Span::from(segment.to_string()));
}
if let Some(segment) = line_text.get(rel_start..rel_end) {
spans.push(Span::styled(segment.to_string(), element_style));
cursor = end;
}
}
let rel_cursor = cursor - line_start;
if cursor < line_end
&& line_text.is_char_boundary(rel_cursor)
&& let Some(segment) = line_text.get(rel_cursor..)
{
spans.push(Span::from(segment.to_string()));
}
let line = if spans.is_empty() {
Line::from(line_text.to_string()).style(style)
} else {
Line::from(spans).style(style)
};
raw_lines.push(line);
// Split on '\n' so any '\r' stays in the line; advancing by 1 accounts
// for the separator byte.
offset = line_end + 1;
}
raw_lines
}
fn remote_image_display_line(style: Style, index: usize) -> Line<'static> {
Line::from(local_image_label_text(index)).style(style)
}
fn trim_trailing_blank_lines(mut lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
while lines
.last()
.is_some_and(|line| line.spans.iter().all(|span| span.content.trim().is_empty()))
{
lines.pop();
}
lines
}
impl HistoryCell for UserHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let wrap_width = width
.saturating_sub(
LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */
)
.max(1);
let style = user_message_style();
let element_style = style.fg(Color::Cyan);
let wrapped_remote_images = if self.remote_image_urls.is_empty() {
None
} else {
Some(adaptive_wrap_lines(
self.remote_image_urls
.iter()
.enumerate()
.map(|(idx, _url)| {
remote_image_display_line(element_style, idx.saturating_add(1))
}),
RtOptions::new(usize::from(wrap_width))
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
))
};
let wrapped_message = if self.message.is_empty() && self.text_elements.is_empty() {
None
} else if self.text_elements.is_empty() {
let message_without_trailing_newlines = self.message.trim_end_matches(['\r', '\n']);
let wrapped = adaptive_wrap_lines(
message_without_trailing_newlines
.split('\n')
.map(|line| Line::from(line).style(style)),
// Wrap algorithm matches textarea.rs.
RtOptions::new(usize::from(wrap_width))
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
);
let wrapped = trim_trailing_blank_lines(wrapped);
(!wrapped.is_empty()).then_some(wrapped)
} else {
let raw_lines = build_user_message_lines_with_elements(
&self.message,
&self.text_elements,
style,
element_style,
);
let wrapped = adaptive_wrap_lines(
raw_lines,
RtOptions::new(usize::from(wrap_width))
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
);
let wrapped = trim_trailing_blank_lines(wrapped);
(!wrapped.is_empty()).then_some(wrapped)
};
if wrapped_remote_images.is_none() && wrapped_message.is_none() {
return Vec::new();
}
let mut lines: Vec<Line<'static>> = vec![Line::from("").style(style)];
if let Some(wrapped_remote_images) = wrapped_remote_images {
lines.extend(prefix_lines(
wrapped_remote_images,
" ".into(),
" ".into(),
));
if wrapped_message.is_some() {
lines.push(Line::from("").style(style));
}
}
if let Some(wrapped_message) = wrapped_message {
lines.extend(prefix_lines(
wrapped_message,
" ".bold().dim(),
" ".into(),
));
}
lines.push(Line::from("").style(style));
lines
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let mut lines = raw_lines_from_source(self.message.trim_end_matches(['\r', '\n']));
if !self.remote_image_urls.is_empty() {
if !lines.is_empty() {
lines.push(Line::from(""));
}
lines.extend(
self.remote_image_urls
.iter()
.enumerate()
.map(|(idx, _url)| Line::from(local_image_label_text(idx.saturating_add(1)))),
);
}
lines
}
}
#[derive(Debug)]
pub(crate) struct ReasoningSummaryCell {
_header: String,
content: String,
/// Session cwd used to render local file links inside the reasoning body.
cwd: PathBuf,
transcript_only: bool,
}
impl ReasoningSummaryCell {
/// Create a reasoning summary cell that will render local file links relative to the session
/// cwd active when the summary was recorded.
pub(crate) fn new(header: String, content: String, cwd: &Path, transcript_only: bool) -> Self {
Self {
_header: header,
content,
cwd: cwd.to_path_buf(),
transcript_only,
}
}
fn lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
append_markdown(
&self.content,
crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2),
Some(self.cwd.as_path()),
&mut lines,
);
let summary_style = Style::default().dim().italic();
let summary_lines = lines
.into_iter()
.map(|mut line| {
line.spans = line
.spans
.into_iter()
.map(|span| span.patch_style(summary_style))
.collect();
line
})
.collect::<Vec<_>>();
adaptive_wrap_lines(
&summary_lines,
RtOptions::new(width as usize)
.initial_indent("".dim().into())
.subsequent_indent(" ".into()),
)
}
}
impl HistoryCell for ReasoningSummaryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if self.transcript_only {
Vec::new()
} else {
self.lines(width)
}
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
if self.transcript_only {
Vec::new()
} else {
raw_lines_from_source(self.content.trim())
}
}
}
#[derive(Debug)]
pub(crate) struct AgentMessageCell {
lines: Vec<Line<'static>>,
is_first_line: bool,
}
impl AgentMessageCell {
pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {
Self {
lines,
is_first_line,
}
}
}
impl HistoryCell for AgentMessageCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
adaptive_wrap_lines(
&self.lines,
RtOptions::new(width as usize)
.initial_indent(if self.is_first_line {
"".dim().into()
} else {
" ".into()
})
.subsequent_indent(" ".into()),
)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.lines.clone())
}
fn is_stream_continuation(&self) -> bool {
!self.is_first_line
}
}
/// A consolidated agent message cell that stores raw markdown source and re-renders from it.
///
/// After a stream finalizes, the `ConsolidateAgentMessage` handler in `App`
/// replaces the contiguous run of `AgentMessageCell`s with a single
/// `AgentMarkdownCell`. On terminal resize, `display_lines(width)` re-renders
/// from source via `append_markdown_agent`, producing correctly-sized tables
/// with box-drawing borders.
///
/// The cell snapshots `cwd` at construction so local file-link display remains aligned with the
/// session that produced the message. Reusing the current process cwd during reflow would make old
/// transcript content change meaning after a later `/cd` or resumed session.
#[derive(Debug)]
pub(crate) struct AgentMarkdownCell {
markdown_source: String,
cwd: PathBuf,
}
impl AgentMarkdownCell {
/// Create a finalized source-backed assistant message cell.
///
/// `markdown_source` must be the raw source accumulated by the stream controller, not already
/// wrapped terminal lines. Passing rendered lines here would make future resize reflow preserve
/// stale wrapping instead of repairing it.
pub(crate) fn new(markdown_source: String, cwd: &Path) -> Self {
Self {
markdown_source,
cwd: cwd.to_path_buf(),
}
}
}
impl HistoryCell for AgentMarkdownCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let Some(wrap_width) =
crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2)
else {
return prefix_lines(vec![Line::default()], "".dim(), " ".into());
};
let mut lines: Vec<Line<'static>> = Vec::new();
// Re-render markdown from source at the current width. Reserve 2 columns for the "• " /
// " " prefix prepended below.
crate::markdown::append_markdown_agent_with_cwd(
&self.markdown_source,
Some(wrap_width),
Some(self.cwd.as_path()),
&mut lines,
);
prefix_lines(lines, "".dim(), " ".into())
}
fn raw_lines(&self) -> Vec<Line<'static>> {
raw_lines_from_source(&self.markdown_source)
}
}
/// Transient active-cell representation of the mutable tail of an agent stream.
///
/// During streaming, lines that have not yet been committed to scrollback because they belong to
/// an in-progress table are displayed via this cell in the `active_cell` slot. It is replaced on
/// every delta and cleared when the stream finalizes.
#[derive(Debug)]
pub(crate) struct StreamingAgentTailCell {
lines: Vec<Line<'static>>,
is_first_line: bool,
}
impl StreamingAgentTailCell {
pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {
Self {
lines,
is_first_line,
}
}
}
impl HistoryCell for StreamingAgentTailCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
// Tail lines are already rendered at the controller's current stream width.
// Re-wrapping them here can split table borders and produce malformed in-flight rows.
prefix_lines(
self.lines.clone(),
if self.is_first_line {
"".dim()
} else {
" ".into()
},
" ".into(),
)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
fn is_stream_continuation(&self) -> bool {
!self.is_first_line
}
}
pub(crate) fn new_user_prompt(
message: String,
text_elements: Vec<TextElement>,
local_image_paths: Vec<PathBuf>,
remote_image_urls: Vec<String>,
) -> UserHistoryCell {
UserHistoryCell {
message,
text_elements,
local_image_paths,
remote_image_urls,
}
}
/// Create the reasoning history cell emitted at the end of a reasoning block.
///
/// The helper snapshots `cwd` into the returned cell so local file links render the same way they
/// did while the turn was live, even if rendering happens after other app state has advanced.
pub(crate) fn new_reasoning_summary_block(
full_reasoning_buffer: String,
cwd: &Path,
) -> Box<dyn HistoryCell> {
let cwd = cwd.to_path_buf();
let full_reasoning_buffer = full_reasoning_buffer.trim();
if let Some(open) = full_reasoning_buffer.find("**") {
let after_open = &full_reasoning_buffer[(open + 2)..];
if let Some(close) = after_open.find("**") {
let after_close_idx = open + 2 + close + 2;
// if we don't have anything beyond `after_close_idx`
// then we don't have a summary to inject into history
if after_close_idx < full_reasoning_buffer.len() {
let header_buffer = full_reasoning_buffer[..after_close_idx].to_string();
let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string();
// Preserve the session cwd so local file links render the same way in the
// collapsed reasoning block as they did while streaming live content.
return Box::new(ReasoningSummaryCell::new(
header_buffer,
summary_buffer,
&cwd,
/*transcript_only*/ false,
));
}
}
}
Box::new(ReasoningSummaryCell::new(
"".to_string(),
full_reasoning_buffer.to_string(),
&cwd,
/*transcript_only*/ true,
))
}
+300
View File
@@ -0,0 +1,300 @@
//! 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::diff_model::FileChange;
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_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::legacy_core::config::Config;
use crate::live_wrap::take_prefix_by_width;
use crate::markdown::append_markdown;
use crate::markdown::append_markdown_agent_with_cwd;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
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::session_state::ThreadSessionState;
use crate::style::proposed_plan_style;
use crate::style::user_message_style;
#[cfg(test)]
use crate::test_support::PathBufExt;
#[cfg(test)]
use crate::test_support::test_path_buf;
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_app_server_protocol::AskForApproval;
use codex_app_server_protocol::McpAuthStatus;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::McpServerStatusDetail;
use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile;
use codex_app_server_protocol::PermissionProfileFileSystemPermissions;
use codex_app_server_protocol::PermissionProfileNetworkPermissions;
use codex_app_server_protocol::ToolRequestUserInputAnswer;
use codex_app_server_protocol::ToolRequestUserInputQuestion;
use codex_app_server_protocol::WebSearchAction;
use codex_config::types::McpServerTransportConfig;
#[cfg(test)]
use codex_mcp::qualified_mcp_tool_name_prefix;
use codex_otel::RuntimeMetricsSummary;
use codex_protocol::account::PlanType;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkPolicyAmendment;
#[cfg(test)]
use codex_protocol::mcp::Resource;
#[cfg(test)]
use codex_protocol::mcp::ResourceTemplate;
use codex_protocol::models::PermissionProfile;
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::user_input::TextElement;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::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::Clear;
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;
use url::Url;
const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000;
const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000;
mod approvals;
mod base;
mod exec;
mod hook_cell;
mod mcp;
mod messages;
mod notices;
mod patches;
mod plans;
mod request_user_input;
mod search;
mod separators;
mod session;
pub(crate) use approvals::*;
pub(crate) use base::*;
pub(crate) use exec::*;
pub(crate) use hook_cell::HookCell;
pub(crate) use hook_cell::new_active_hook_cell;
pub(crate) use hook_cell::new_completed_hook_cell;
pub(crate) use mcp::*;
pub(crate) use messages::*;
pub(crate) use notices::*;
pub(crate) use patches::*;
pub(crate) use plans::*;
pub(crate) use request_user_input::*;
pub(crate) use search::*;
pub(crate) use separators::*;
pub(crate) use session::*;
#[cfg(test)]
mod tests;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum HistoryRenderMode {
Rich,
Raw,
}
pub(crate) fn raw_lines_from_source(source: &str) -> Vec<Line<'static>> {
if source.is_empty() {
return Vec::new();
}
let mut parts = source.split('\n').collect::<Vec<_>>();
if source.ends_with('\n') {
parts.pop();
}
parts
.into_iter()
.map(|line| Line::from(line.to_string()))
.collect()
}
pub(crate) fn plain_lines(lines: impl IntoIterator<Item = Line<'static>>) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|line| {
let text = line
.spans
.into_iter()
.map(|span| span.content.into_owned())
.collect::<String>();
Line::from(text)
})
.collect()
}
/// 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 copy-friendly plain logical lines for raw scrollback mode.
fn raw_lines(&self) -> Vec<Line<'static>>;
fn display_lines_for_mode(&self, width: u16, mode: HistoryRenderMode) -> Vec<Line<'static>> {
match mode {
HistoryRenderMode::Rich => self.display_lines(width),
HistoryRenderMode::Raw => self.raw_lines(),
}
}
/// 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 {
self.desired_height_for_mode(width, HistoryRenderMode::Rich)
}
fn desired_height_for_mode(&self, width: u16, mode: HistoryRenderMode) -> u16 {
Paragraph::new(Text::from(self.display_lines_for_mode(width, mode)))
.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)
};
// Active-cell content can reflow dramatically during resize/stream updates. Clear the
// entire draw area first so stale glyphs from previous frames never linger.
Clear.render(area, buf);
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
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Informational, warning, update, and policy notice history cells.
use super::*;
#[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)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let update_instruction = if let Some(update_action) = self.update_action {
format!("Run {} to update.", update_action.command_str())
} else {
"See https://github.com/openai/codex for installation options.".to_string()
};
vec![
Line::from("Update available!"),
Line::from(format!("{CODEX_CLI_VERSION} -> {}", self.latest_version)),
Line::from(update_instruction),
Line::from(""),
Line::from("See full release notes:"),
Line::from("https://github.com/openai/codex/releases/latest"),
]
}
}
#[allow(clippy::disallowed_methods)]
pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
PrefixedWrappedHistoryCell::new(message.yellow(), "".yellow(), " ")
}
const TRUSTED_ACCESS_FOR_CYBER_URL: &str = "https://chatgpt.com/cyber";
#[derive(Debug)]
pub(crate) struct CyberPolicyNoticeCell;
pub(crate) fn new_cyber_policy_error_event() -> CyberPolicyNoticeCell {
CyberPolicyNoticeCell
}
impl HistoryCell for CyberPolicyNoticeCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(
vec![
"".cyan(),
"This chat was flagged for possible cybersecurity risk".bold(),
]
.into(),
);
let wrap_width = width.saturating_sub(2).max(1) as usize;
let body = Line::from(vec![
" If this seems wrong, try rephrasing your request. To get authorized for security work, join the "
.dim(),
"Trusted Access for Cyber".cyan().underlined(),
" program.".dim(),
]);
let wrapped = adaptive_wrap_line(
&body,
RtOptions::new(wrap_width).subsequent_indent(" ".into()),
);
push_owned_lines(&wrapped, &mut lines);
lines.push(
vec![
" ".into(),
TRUSTED_ACCESS_FOR_CYBER_URL.cyan().underlined(),
]
.into(),
);
lines
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![
Line::from("This chat was flagged for possible cybersecurity risk"),
Line::from(
"If this seems wrong, try rephrasing your request. To get authorized for security work, join the Trusted Access for Cyber program.",
),
Line::from(TRUSTED_ACCESS_FOR_CYBER_URL),
]
}
}
#[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
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(self.summary.clone())];
if let Some(details) = &self.details {
lines.extend(raw_lines_from_source(details));
}
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 }
}
+93
View File
@@ -0,0 +1,93 @@
//! Patch summaries and image-tool transcript helpers.
use super::*;
#[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)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(create_diff_summary(
&self.changes,
&self.cwd,
RAW_DIFF_SUMMARY_WIDTH,
))
}
}
/// Create a new `PendingPatch` cell that lists the filelevel summary of
/// a proposed patch. The summary lines should already be formatted (e.g.
/// "A path/to/file.rs").
pub(crate) fn new_patch_event(
changes: HashMap<PathBuf, FileChange>,
cwd: &Path,
) -> PatchHistoryCell {
PatchHistoryCell {
changes,
cwd: cwd.to_path_buf(),
}
}
pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = Vec::new();
// Failure title
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
if !stderr.trim().is_empty() {
let output = output_lines(
Some(&CommandOutput {
exit_code: 1,
formatted_output: String::new(),
aggregated_output: stderr,
}),
OutputLinesParams {
line_limit: TOOL_CALL_MAX_LINES,
only_err: true,
include_angle_pipe: true,
include_prefix: true,
},
);
lines.extend(output.lines);
}
PlainHistoryCell { lines }
}
pub(crate) fn new_view_image_tool_call(path: AbsolutePathBuf, cwd: &Path) -> PlainHistoryCell {
let display_path = display_path_for(path.as_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_image_generation_call(
call_id: String,
revised_prompt: Option<String>,
saved_path: Option<AbsolutePathBuf>,
) -> PlainHistoryCell {
let detail = revised_prompt.unwrap_or_else(|| call_id.clone());
let mut lines: Vec<Line<'static>> = vec![
vec!["".dim(), "Generated Image:".bold()].into(),
vec!["".dim(), detail.dim()].into(),
];
if let Some(saved_path) = saved_path {
let saved_path = Url::from_file_path(saved_path.as_path())
.map(|url| url.to_string())
.unwrap_or_else(|_| saved_path.display().to_string());
lines.push(vec!["".dim(), "Saved to: ".dim(), saved_path.into()].into());
}
PlainHistoryCell { lines }
}
+215
View File
@@ -0,0 +1,215 @@
//! Proposed-plan and plan-update history cells.
use super::*;
/// Transient active-cell representation of the mutable tail of a proposed-plan stream.
///
/// The controller prepares the full styled plan lines because plan tails need the same header,
/// padding, and background treatment as committed `ProposedPlanStreamCell`s while remaining
/// preview-only during streaming.
#[derive(Debug)]
pub(crate) struct StreamingPlanTailCell {
lines: Vec<Line<'static>>,
is_stream_continuation: bool,
}
impl StreamingPlanTailCell {
pub(crate) fn new(lines: Vec<Line<'static>>, is_stream_continuation: bool) -> Self {
Self {
lines,
is_stream_continuation,
}
}
}
impl HistoryCell for StreamingPlanTailCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.lines.clone())
}
fn is_stream_continuation(&self) -> bool {
self.is_stream_continuation
}
}
/// Render a userfriendly plan update styled like a checkbox todo list.
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
let UpdatePlanArgs { explanation, plan } = update;
PlanUpdateCell { explanation, plan }
}
/// Create a proposed-plan cell that snapshots the session cwd for later markdown rendering.
///
/// The plan body is stored as raw markdown so terminal resize reflow can render it again at the
/// current width. Callers should use `new_proposed_plan_stream` only for transient live streaming
/// cells, then consolidate to this source-backed cell when the plan is complete.
pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPlanCell {
ProposedPlanCell {
plan_markdown,
cwd: cwd.to_path_buf(),
}
}
/// Create a transient proposed-plan stream cell from already rendered lines.
///
/// Stream cells are display fragments, not source-backed history. They should be replaced by
/// `ProposedPlanCell` during consolidation before relying on resize reflow for finalized history.
pub(crate) fn new_proposed_plan_stream(
lines: Vec<Line<'static>>,
is_stream_continuation: bool,
) -> ProposedPlanStreamCell {
ProposedPlanStreamCell {
lines,
is_stream_continuation,
}
}
/// Finalized proposed-plan history that can render itself again for a new width.
///
/// This is the source-backed counterpart to `ProposedPlanStreamCell`. It owns raw markdown and the
/// session cwd needed for stable local-link rendering during later transcript reflow.
#[derive(Debug)]
pub(crate) struct ProposedPlanCell {
plan_markdown: String,
/// Session cwd used to keep local file-link display aligned with live streamed plan rendering.
cwd: PathBuf,
}
/// Transient proposed-plan history emitted while a plan is still streaming.
///
/// The lines are already rendered for the stream's current width. A finalized transcript should not
/// keep these cells after consolidation, because they cannot re-render their source on a later
/// terminal resize.
#[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_agent_with_cwd(
&self.plan_markdown,
Some(wrap_width),
Some(self.cwd.as_path()),
&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
}
fn raw_lines(&self) -> Vec<Line<'static>> {
raw_lines_from_source(&self.plan_markdown)
}
}
impl HistoryCell for ProposedPlanStreamCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.lines.clone())
}
fn is_stream_continuation(&self) -> bool {
self.is_stream_continuation
}
}
#[derive(Debug)]
pub(crate) struct PlanUpdateCell {
explanation: Option<String>,
plan: Vec<PlanItemArg>,
}
impl HistoryCell for PlanUpdateCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let render_note = |text: &str| -> Vec<Line<'static>> {
let wrap_width = width.saturating_sub(4).max(1) as usize;
let note = Line::from(text.to_string().dim().italic());
let wrapped = adaptive_wrap_line(&note, RtOptions::new(wrap_width));
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
};
let render_step = |status: &StepStatus, text: &str| -> Vec<Line<'static>> {
let (box_str, step_style) = match status {
StepStatus::Completed => ("", Style::default().crossed_out().dim()),
StepStatus::InProgress => ("", Style::default().cyan().bold()),
StepStatus::Pending => ("", Style::default().dim()),
};
let opts = RtOptions::new(width.saturating_sub(4).max(1) as usize)
.initial_indent(box_str.into())
.subsequent_indent(" ".into());
let step = Line::from(text.to_string().set_style(step_style));
let wrapped = adaptive_wrap_line(&step, opts);
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
};
let mut lines: Vec<Line<'static>> = vec![];
lines.push(vec!["".dim(), "Updated Plan".bold()].into());
let mut indented_lines = vec![];
let note = self
.explanation
.as_ref()
.map(|s| s.trim())
.filter(|t| !t.is_empty());
if let Some(expl) = note {
indented_lines.extend(render_note(expl));
};
if self.plan.is_empty() {
indented_lines.push(Line::from("(no steps provided)".dim().italic()));
} else {
for PlanItemArg { step, status } in self.plan.iter() {
indented_lines.extend(render_step(status, step));
}
}
lines.extend(prefix_lines(indented_lines, "".dim(), " ".into()));
lines
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let mut lines = vec![Line::from("Updated Plan")];
if let Some(explanation) = self
.explanation
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
lines.extend(raw_lines_from_source(explanation));
}
if self.plan.is_empty() {
lines.push(Line::from("(no steps provided)"));
} else {
for PlanItemArg { step, status } in &self.plan {
lines.push(Line::from(format!("{status:?}: {step}")));
}
}
lines
}
}
@@ -0,0 +1,187 @@
//! Completed request-user-input transcript rendering.
use super::*;
/// Renders a completed (or interrupted) request_user_input exchange in history.
#[derive(Debug)]
pub(crate) struct RequestUserInputResultCell {
pub(crate) questions: Vec<ToolRequestUserInputQuestion>,
pub(crate) answers: HashMap<String, ToolRequestUserInputAnswer>,
pub(crate) interrupted: bool,
}
impl HistoryCell for RequestUserInputResultCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let width = width.max(1) as usize;
let total = self.questions.len();
let answered = self
.questions
.iter()
.filter(|question| {
self.answers
.get(&question.id)
.is_some_and(|answer| !answer.answers.is_empty())
})
.count();
let unanswered = total.saturating_sub(answered);
let mut header = vec!["".dim(), " ".into(), "Questions".bold()];
header.push(format!(" {answered}/{total} answered").dim());
if self.interrupted {
header.push(" (interrupted)".cyan());
}
let mut lines: Vec<Line<'static>> = vec![header.into()];
for question in &self.questions {
let answer = self.answers.get(&question.id);
let answer_missing = match answer {
Some(answer) => answer.answers.is_empty(),
None => true,
};
let mut question_lines = wrap_with_prefix(
&question.question,
width,
"".into(),
" ".into(),
Style::default(),
);
if answer_missing && let Some(last) = question_lines.last_mut() {
last.spans.push(" (unanswered)".dim());
}
lines.extend(question_lines);
let Some(answer) = answer.filter(|answer| !answer.answers.is_empty()) else {
continue;
};
if question.is_secret {
lines.extend(wrap_with_prefix(
"••••••",
width,
" answer: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
));
continue;
}
let (options, note) = split_request_user_input_answer(answer);
for option in options {
lines.extend(wrap_with_prefix(
&option,
width,
" answer: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
));
}
if let Some(note) = note {
let (label, continuation, style) = if question.options.is_some() {
(
" note: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
)
} else {
(
" answer: ".dim(),
" ".dim(),
Style::default().fg(Color::Cyan),
)
};
lines.extend(wrap_with_prefix(&note, width, label, continuation, style));
}
}
if self.interrupted && unanswered > 0 {
let summary = format!("interrupted with {unanswered} unanswered");
lines.extend(wrap_with_prefix(
&summary,
width,
"".cyan().dim(),
" ".dim(),
Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
));
}
lines
}
fn raw_lines(&self) -> Vec<Line<'static>> {
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 mut lines = vec![Line::from(format!("Questions {answered}/{total} answered"))];
if self.interrupted {
lines.push(Line::from("(interrupted)"));
}
for question in &self.questions {
lines.push(Line::from(question.question.clone()));
if let Some(answer) = self
.answers
.get(&question.id)
.filter(|answer| !answer.answers.is_empty())
{
if question.is_secret {
lines.push(Line::from("answer: ******"));
} else {
let (options, note) = split_request_user_input_answer(answer);
lines.extend(
options
.into_iter()
.map(|option| Line::from(format!("answer: {option}"))),
);
if let Some(note) = note {
lines.push(Line::from(format!("note: {note}")));
}
}
} else {
lines.push(Line::from("(unanswered)"));
}
}
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: &ToolRequestUserInputAnswer,
) -> (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)
}
+144
View File
@@ -0,0 +1,144 @@
//! Web-search activity history cells.
use super::*;
fn web_search_header(completed: bool) -> &'static str {
if completed {
"Searched"
} else {
"Searching the web"
}
}
fn web_search_action_detail(action: &WebSearchAction) -> String {
match action {
WebSearchAction::Search { query, queries } => {
query.clone().filter(|q| !q.is_empty()).unwrap_or_else(|| {
let items = queries.as_ref();
let first = items
.and_then(|queries| queries.first())
.cloned()
.unwrap_or_default();
if items.is_some_and(|queries| queries.len() > 1) && !first.is_empty() {
format!("{first} ...")
} else {
first
}
})
}
WebSearchAction::OpenPage { url } => url.clone().unwrap_or_default(),
WebSearchAction::FindInPage { url, pattern } => match (pattern, url) {
(Some(pattern), Some(url)) => format!("'{pattern}' in {url}"),
(Some(pattern), None) => format!("'{pattern}'"),
(None, Some(url)) => url.clone(),
(None, None) => String::new(),
},
WebSearchAction::Other => String::new(),
}
}
fn web_search_detail(action: Option<&WebSearchAction>, query: &str) -> String {
let detail = action.map(web_search_action_detail).unwrap_or_default();
if detail.is_empty() {
query.to_string()
} else {
detail
}
}
#[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 {
activity_indicator(
Some(self.start_time),
MotionMode::from_animations_enabled(self.animations_enabled),
ReducedMotionIndicator::StaticBullet,
)
.unwrap_or_else(|| "".dim())
};
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)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let header = web_search_header(self.completed);
let detail = web_search_detail(self.action.as_ref(), &self.query);
if detail.is_empty() {
vec![Line::from(header)]
} else {
vec![Line::from(format!("{header} {detail}"))]
}
}
}
pub(crate) fn new_active_web_search_call(
call_id: String,
query: String,
animations_enabled: bool,
) -> WebSearchCell {
WebSearchCell::new(call_id, query, /*action*/ 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),
/*animations_enabled*/ false,
);
cell.complete();
cell
}
+171
View File
@@ -0,0 +1,171 @@
//! Turn separators and runtime-metrics labels for transcript history.
use super::*;
#[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; completed turns should pass protocol turn duration when available.
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(crate::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(),
]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let mut label_parts = Vec::new();
if let Some(elapsed_seconds) = self
.elapsed_seconds
.filter(|seconds| *seconds > 60)
.map(crate::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() {
Vec::new()
} else {
vec![Line::from(label_parts.join(""))]
}
}
}
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 }
}
+429
View File
@@ -0,0 +1,429 @@
//! Session headers, onboarding guidance, and transcript cards.
use super::*;
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, /*forced_inner_width*/ 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,
cwd: PathBuf,
}
impl TooltipHistoryCell {
fn new(tip: String, cwd: &Path) -> Self {
Self {
tip,
cwd: cwd.to_path_buf(),
}
}
}
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),
Some(self.cwd.as_path()),
&mut lines,
);
prefix_lines(lines, indent.into(), indent.into())
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![Line::from(format!("Tip: {}", self.tip))]
}
}
#[derive(Debug)]
pub struct SessionInfoCell(CompositeHistoryCell);
impl HistoryCell for SessionInfoCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.0.display_lines(width)
}
fn desired_height(&self, width: u16) -> u16 {
self.0.desired_height(width)
}
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
self.0.transcript_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
self.0.raw_lines()
}
}
pub(crate) fn new_session_info(
config: &Config,
requested_model: &str,
session: &ThreadSessionState,
is_first_event: bool,
tooltip_override: Option<String>,
auth_plan: Option<PlanType>,
show_fast_status: bool,
) -> SessionInfoCell {
// Header box rendered as history (so it appears at the very top)
let header = SessionHeaderHistoryCell::new(
session.model.clone(),
session.reasoning_effort,
show_fast_status,
config.cwd.to_path_buf(),
CODEX_CLI_VERSION,
)
.with_yolo_mode(has_yolo_permissions(
session.approval_policy,
&session.permission_profile,
));
let mut parts: Vec<Box<dyn HistoryCell>> = vec![Box::new(header)];
if is_first_event {
// Help lines below the header (new copy and list)
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) = tooltip_override
.or_else(|| tooltips::get_tooltip(auth_plan, show_fast_status))
.map(|tip| TooltipHistoryCell::new(tip, &config.cwd))
{
parts.push(Box::new(tooltips));
}
if requested_model != session.model.as_str() {
let lines = vec![
"model changed:".magenta().bold().into(),
format!("requested: {requested_model}").into(),
format!("used: {}", session.model).into(),
];
parts.push(Box::new(PlainHistoryCell { lines }));
}
}
SessionInfoCell(CompositeHistoryCell { parts })
}
pub(crate) fn is_yolo_mode(config: &Config) -> bool {
has_yolo_permissions(
AskForApproval::from(config.permissions.approval_policy.value()),
&config.permissions.effective_permission_profile(),
)
}
pub(crate) fn has_yolo_permissions(
approval_policy: AskForApproval,
permission_profile: &PermissionProfile,
) -> bool {
let permission_profile = AppServerPermissionProfile::from(permission_profile.clone());
approval_policy == AskForApproval::Never
&& matches!(
permission_profile,
AppServerPermissionProfile::Disabled
| AppServerPermissionProfile::Managed {
file_system: PermissionProfileFileSystemPermissions::Unrestricted,
network: PermissionProfileNetworkPermissions { enabled: true },
}
)
}
#[derive(Debug)]
pub(crate) struct SessionHeaderHistoryCell {
version: &'static str,
model: String,
model_style: Style,
reasoning_effort: Option<ReasoningEffortConfig>,
show_fast_status: bool,
directory: PathBuf,
yolo_mode: bool,
}
impl SessionHeaderHistoryCell {
pub(crate) fn new(
model: String,
reasoning_effort: Option<ReasoningEffortConfig>,
show_fast_status: bool,
directory: PathBuf,
version: &'static str,
) -> Self {
Self::new_with_style(
model,
Style::default(),
reasoning_effort,
show_fast_status,
directory,
version,
)
}
pub(crate) fn new_with_style(
model: String,
model_style: Style,
reasoning_effort: Option<ReasoningEffortConfig>,
show_fast_status: bool,
directory: PathBuf,
version: &'static str,
) -> Self {
Self {
version,
model,
model_style,
reasoning_effort,
show_fast_status,
directory,
yolo_mode: false,
}
}
pub(crate) fn with_yolo_mode(mut self, yolo_mode: bool) -> Self {
self.yolo_mode = yolo_mode;
self
}
fn format_directory(&self, max_width: Option<usize>) -> String {
Self::format_directory_inner(&self.directory, max_width)
}
pub(crate) 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",
})
}
}
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:";
const PERMISSIONS_LABEL: &str = "permissions:";
let label_width = if self.yolo_mode {
DIR_LABEL.len().max(PERMISSIONS_LABEL.len())
} else {
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));
}
if self.show_fast_status {
spans.push(" ".into());
spans.push(Span::styled("fast", self.model_style.magenta()));
}
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 mut lines = vec![
make_row(title_spans),
make_row(Vec::new()),
make_row(model_spans),
make_row(dir_spans),
];
if self.yolo_mode {
let permissions_label = format!("{PERMISSIONS_LABEL:<label_width$}");
lines.push(make_row(vec![
Span::from(format!("{permissions_label} ")).dim(),
"YOLO mode".magenta().bold(),
]));
}
with_border(lines)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
let mut lines = vec![
Line::from(format!("OpenAI Codex (v{})", self.version)),
Line::from(format!(
"model: {}{}",
self.model,
self.reasoning_label()
.map(|reasoning| format!(" {reasoning}"))
.unwrap_or_default()
)),
Line::from(format!(
"directory: {}",
self.format_directory(/*max_width*/ None)
)),
];
if self.yolo_mode {
lines.push(Line::from("permissions: YOLO mode"));
}
lines
}
}
File diff suppressed because it is too large Load Diff