mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
d1235a0a78
commit
e6a7368810
File diff suppressed because it is too large
Load Diff
@@ -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())],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 file‑level summary of
|
||||
/// a proposed patch. The summary lines should already be formatted (e.g.
|
||||
/// "A path/to/file.rs").
|
||||
pub(crate) fn new_patch_event(
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
cwd: &Path,
|
||||
) -> PatchHistoryCell {
|
||||
PatchHistoryCell {
|
||||
changes,
|
||||
cwd: cwd.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
// Failure title
|
||||
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
|
||||
|
||||
if !stderr.trim().is_empty() {
|
||||
let output = output_lines(
|
||||
Some(&CommandOutput {
|
||||
exit_code: 1,
|
||||
formatted_output: String::new(),
|
||||
aggregated_output: stderr,
|
||||
}),
|
||||
OutputLinesParams {
|
||||
line_limit: TOOL_CALL_MAX_LINES,
|
||||
only_err: true,
|
||||
include_angle_pipe: true,
|
||||
include_prefix: true,
|
||||
},
|
||||
);
|
||||
lines.extend(output.lines);
|
||||
}
|
||||
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_view_image_tool_call(path: 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 }
|
||||
}
|
||||
@@ -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 user‑friendly plan update styled like a checkbox todo list.
|
||||
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
|
||||
let UpdatePlanArgs { explanation, plan } = update;
|
||||
PlanUpdateCell { explanation, plan }
|
||||
}
|
||||
|
||||
/// 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(¬e, RtOptions::new(wrap_width));
|
||||
let mut out = Vec::new();
|
||||
push_owned_lines(&wrapped, &mut out);
|
||||
out
|
||||
};
|
||||
|
||||
let render_step = |status: &StepStatus, text: &str| -> Vec<Line<'static>> {
|
||||
let (box_str, step_style) = match status {
|
||||
StepStatus::Completed => ("✔ ", Style::default().crossed_out().dim()),
|
||||
StepStatus::InProgress => ("□ ", Style::default().cyan().bold()),
|
||||
StepStatus::Pending => ("□ ", Style::default().dim()),
|
||||
};
|
||||
|
||||
let opts = RtOptions::new(width.saturating_sub(4).max(1) as usize)
|
||||
.initial_indent(box_str.into())
|
||||
.subsequent_indent(" ".into());
|
||||
let step = Line::from(text.to_string().set_style(step_style));
|
||||
let wrapped = adaptive_wrap_line(&step, opts);
|
||||
let mut out = Vec::new();
|
||||
push_owned_lines(&wrapped, &mut out);
|
||||
out
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![];
|
||||
lines.push(vec!["• ".dim(), "Updated Plan".bold()].into());
|
||||
|
||||
let mut indented_lines = vec![];
|
||||
let note = self
|
||||
.explanation
|
||||
.as_ref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|t| !t.is_empty());
|
||||
if let Some(expl) = note {
|
||||
indented_lines.extend(render_note(expl));
|
||||
};
|
||||
|
||||
if self.plan.is_empty() {
|
||||
indented_lines.push(Line::from("(no steps provided)".dim().italic()));
|
||||
} else {
|
||||
for PlanItemArg { step, status } in self.plan.iter() {
|
||||
indented_lines.extend(render_step(status, step));
|
||||
}
|
||||
}
|
||||
lines.extend(prefix_lines(indented_lines, " └ ".dim(), " ".into()));
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
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(¬e, width, label, continuation, style));
|
||||
}
|
||||
}
|
||||
|
||||
if self.interrupted && unanswered > 0 {
|
||||
let summary = format!("interrupted with {unanswered} unanswered");
|
||||
lines.extend(wrap_with_prefix(
|
||||
&summary,
|
||||
width,
|
||||
" ↳ ".cyan().dim(),
|
||||
" ".dim(),
|
||||
Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
|
||||
));
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user