mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
Compare commits
1 Commits
rust-v0.95
...
joshka/ref
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18123804ce |
8
codex-rs/Cargo.lock
generated
8
codex-rs/Cargo.lock
generated
@@ -1537,6 +1537,7 @@ dependencies = [
|
||||
"dirs",
|
||||
"dunce",
|
||||
"image",
|
||||
"indoc",
|
||||
"insta",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -3308,9 +3309,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indoc"
|
||||
version = "2.0.6"
|
||||
version = "2.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
|
||||
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
|
||||
@@ -90,6 +90,7 @@ unicode-width = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
codex-windows-sandbox = { workspace = true }
|
||||
indoc = "2.0.7"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 1412
|
||||
expression: last
|
||||
---
|
||||
■ Conversation interrupted - tell the model what to do differently. Something went wrong? Hit `/feedback` to report the issue.
|
||||
■ Conversation interrupted - tell the model what to do differently. Something
|
||||
went wrong? Hit `/feedback` to report the issue.
|
||||
|
||||
@@ -4,8 +4,10 @@ use std::time::Instant;
|
||||
use codex_core::protocol::ExecCommandSource;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
|
||||
/// Output captured from a completed exec call, including exit code and combined streams.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct CommandOutput {
|
||||
/// The exit status returned by the command.
|
||||
pub(crate) exit_code: i32,
|
||||
/// The aggregated stderr + stdout interleaved.
|
||||
pub(crate) aggregated_output: String,
|
||||
@@ -13,6 +15,7 @@ pub(crate) struct CommandOutput {
|
||||
pub(crate) formatted_output: String,
|
||||
}
|
||||
|
||||
/// Single exec invocation (shell or tool) as it flows through the history cell.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ExecCall {
|
||||
pub(crate) call_id: String,
|
||||
@@ -25,6 +28,19 @@ pub(crate) struct ExecCall {
|
||||
pub(crate) interaction_input: Option<String>,
|
||||
}
|
||||
|
||||
/// History cell that renders exec/search/read calls with status and wrapped output.
|
||||
///
|
||||
/// Exploring calls collapse search/read/list steps under an "Exploring"/"Explored" header with a
|
||||
/// spinner or bullet. Non-exploration runs render a status bullet plus wrapped command, then a
|
||||
/// tree-prefixed output block that truncates middle lines when necessary.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// • Ran bash -lc "rg term"
|
||||
/// │ Search shimmer_spans in .
|
||||
/// └ (no output)
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ExecCell {
|
||||
pub(crate) calls: Vec<ExecCall>,
|
||||
@@ -32,6 +48,7 @@ pub(crate) struct ExecCell {
|
||||
}
|
||||
|
||||
impl ExecCell {
|
||||
/// Create a new cell with a single active call and control over spinner animation.
|
||||
pub(crate) fn new(call: ExecCall, animations_enabled: bool) -> Self {
|
||||
Self {
|
||||
calls: vec![call],
|
||||
@@ -39,6 +56,10 @@ impl ExecCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append an additional exploring call to the cell if it belongs to the same batch.
|
||||
///
|
||||
/// Exploring calls render together (search/list/read), so when a new call is also exploring we
|
||||
/// coalesce it into the existing cell to avoid noisy standalone entries.
|
||||
pub(crate) fn with_added_call(
|
||||
&self,
|
||||
call_id: String,
|
||||
@@ -67,6 +88,7 @@ impl ExecCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a call as completed with captured output and duration, replacing any spinner.
|
||||
pub(crate) fn complete_call(
|
||||
&mut self,
|
||||
call_id: &str,
|
||||
@@ -80,10 +102,12 @@ impl ExecCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true when the cell has only exploring calls and every call has finished.
|
||||
pub(crate) fn should_flush(&self) -> bool {
|
||||
!self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some())
|
||||
}
|
||||
|
||||
/// Mark in-flight calls as failed, preserving how long they were running.
|
||||
pub(crate) fn mark_failed(&mut self) {
|
||||
for call in self.calls.iter_mut() {
|
||||
if call.output.is_none() {
|
||||
@@ -102,14 +126,17 @@ impl ExecCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether all calls are exploratory (search/list/read) and should render together.
|
||||
pub(crate) fn is_exploring_cell(&self) -> bool {
|
||||
self.calls.iter().all(Self::is_exploring_call)
|
||||
}
|
||||
|
||||
/// True if any call is still active.
|
||||
pub(crate) fn is_active(&self) -> bool {
|
||||
self.calls.iter().any(|c| c.output.is_none())
|
||||
}
|
||||
|
||||
/// Start time of the first active call, used to drive spinners.
|
||||
pub(crate) fn active_start_time(&self) -> Option<Instant> {
|
||||
self.calls
|
||||
.iter()
|
||||
@@ -117,14 +144,17 @@ impl ExecCell {
|
||||
.and_then(|c| c.start_time)
|
||||
}
|
||||
|
||||
/// Whether animated spinners are enabled for active calls.
|
||||
pub(crate) fn animations_enabled(&self) -> bool {
|
||||
self.animations_enabled
|
||||
}
|
||||
|
||||
/// Iterate over contained calls in order for rendering.
|
||||
pub(crate) fn iter_calls(&self) -> impl Iterator<Item = &ExecCall> {
|
||||
self.calls.iter()
|
||||
}
|
||||
|
||||
/// Detect whether a call is exploratory (read/list/search) for coalescing.
|
||||
pub(super) fn is_exploring_call(call: &ExecCall) -> bool {
|
||||
!matches!(call.source, ExecCommandSource::UserShell)
|
||||
&& !call.parsed.is_empty()
|
||||
@@ -140,10 +170,12 @@ impl ExecCell {
|
||||
}
|
||||
|
||||
impl ExecCall {
|
||||
/// Whether the invocation originated from a user shell command.
|
||||
pub(crate) fn is_user_shell_command(&self) -> bool {
|
||||
matches!(self.source, ExecCommandSource::UserShell)
|
||||
}
|
||||
|
||||
/// Whether the invocation expects user input back (unified exec interaction).
|
||||
pub(crate) fn is_unified_exec_interaction(&self) -> bool {
|
||||
matches!(self.source, ExecCommandSource::UnifiedExecInteraction)
|
||||
}
|
||||
|
||||
@@ -17,9 +17,10 @@ use codex_common::elapsed::format_duration;
|
||||
use codex_core::protocol::ExecCommandSource;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use itertools::Itertools;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use textwrap::WordSplitter;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
@@ -27,6 +28,7 @@ pub(crate) const TOOL_CALL_MAX_LINES: usize = 5;
|
||||
const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50;
|
||||
const MAX_INTERACTION_PREVIEW_CHARS: usize = 80;
|
||||
|
||||
/// How much output to include when rendering the output block.
|
||||
pub(crate) struct OutputLinesParams {
|
||||
pub(crate) line_limit: usize,
|
||||
pub(crate) only_err: bool,
|
||||
@@ -34,6 +36,7 @@ pub(crate) struct OutputLinesParams {
|
||||
pub(crate) include_prefix: bool,
|
||||
}
|
||||
|
||||
/// Build a new active exec command cell that animates while running.
|
||||
pub(crate) fn new_active_exec_command(
|
||||
call_id: String,
|
||||
command: Vec<String>,
|
||||
@@ -57,6 +60,7 @@ pub(crate) fn new_active_exec_command(
|
||||
)
|
||||
}
|
||||
|
||||
/// Format the unified exec message shown when the agent interacts with a tool.
|
||||
fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> String {
|
||||
let command_display = command.join(" ");
|
||||
match input {
|
||||
@@ -68,6 +72,7 @@ fn format_unified_exec_interaction(command: &[String], input: Option<&str>) -> S
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim interaction input to a short, single-line preview for the history.
|
||||
fn summarize_interaction_input(input: &str) -> String {
|
||||
let single_line = input.replace('\n', "\\n");
|
||||
let sanitized = single_line.replace('`', "\\`");
|
||||
@@ -89,6 +94,7 @@ pub(crate) struct OutputLines {
|
||||
pub(crate) omitted: Option<usize>,
|
||||
}
|
||||
|
||||
/// Render command output with optional truncation and tree prefixes.
|
||||
pub(crate) fn output_lines(
|
||||
output: Option<&CommandOutput>,
|
||||
params: OutputLinesParams,
|
||||
@@ -172,6 +178,7 @@ pub(crate) fn output_lines(
|
||||
}
|
||||
}
|
||||
|
||||
/// Spinner shown for active exec calls, respecting 16m color when available.
|
||||
pub(crate) fn spinner(start_time: Option<Instant>, animations_enabled: bool) -> Span<'static> {
|
||||
if !animations_enabled {
|
||||
return "•".dim();
|
||||
@@ -189,6 +196,7 @@ pub(crate) fn spinner(start_time: Option<Instant>, animations_enabled: bool) ->
|
||||
}
|
||||
|
||||
impl HistoryCell for ExecCell {
|
||||
/// Render as either an "Exploring" grouped call list or single command/run output.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
if self.is_exploring_cell() {
|
||||
self.exploring_display_lines(width)
|
||||
@@ -197,10 +205,12 @@ impl HistoryCell for ExecCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transcript height matches raw line count because transcript rendering omits wrapping.
|
||||
fn desired_transcript_height(&self, width: u16) -> u16 {
|
||||
self.transcript_lines(width).len() as u16
|
||||
}
|
||||
|
||||
/// Render a transcript-friendly version of the exec calls without UI padding.
|
||||
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = vec![];
|
||||
for (i, call) in self.iter_calls().enumerate() {
|
||||
@@ -242,6 +252,10 @@ impl HistoryCell for ExecCell {
|
||||
}
|
||||
|
||||
impl ExecCell {
|
||||
/// Render exploring reads/searches as a grouped list under a shared header.
|
||||
///
|
||||
/// Collapses sequential reads, dedupes filenames, and prefixes wrapped lines with `└`/spaces
|
||||
/// so the block sits under the "Exploring"/"Explored" status line.
|
||||
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut out: Vec<Line<'static>> = Vec::new();
|
||||
out.push(Line::from(vec![
|
||||
@@ -345,6 +359,10 @@ impl ExecCell {
|
||||
out
|
||||
}
|
||||
|
||||
/// Render a single command invocation with wrapped command and trimmed output.
|
||||
///
|
||||
/// Uses colored bullets for running/success/error, wraps command lines with `│` prefixes, and
|
||||
/// emits a tree-prefixed output block that truncates to the configured maximum lines.
|
||||
fn command_display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let [call] = &self.calls.as_slice() else {
|
||||
panic!("Expected exactly one call in a command display cell");
|
||||
@@ -481,6 +499,7 @@ impl ExecCell {
|
||||
lines
|
||||
}
|
||||
|
||||
/// Keep only the first `keep` lines, replacing the rest with an ellipsis entry.
|
||||
fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec<Line<'static>> {
|
||||
if lines.len() <= keep {
|
||||
return lines.to_vec();
|
||||
@@ -494,6 +513,7 @@ impl ExecCell {
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace the middle of a line list with an ellipsis, preserving head/tail edges.
|
||||
fn truncate_lines_middle(
|
||||
lines: &[Line<'static>],
|
||||
max: usize,
|
||||
@@ -541,11 +561,13 @@ impl ExecCell {
|
||||
out
|
||||
}
|
||||
|
||||
/// Build a dimmed ellipsis line noting how many lines were hidden.
|
||||
fn ellipsis_line(omitted: usize) -> Line<'static> {
|
||||
Line::from(vec![format!("… +{omitted} lines").dim()])
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefix configuration for wrapped command/output sections.
|
||||
#[derive(Clone, Copy)]
|
||||
struct PrefixedBlock {
|
||||
initial_prefix: &'static str,
|
||||
@@ -553,6 +575,7 @@ struct PrefixedBlock {
|
||||
}
|
||||
|
||||
impl PrefixedBlock {
|
||||
/// Define a block with separate first/subsequent prefixes for wrapped content.
|
||||
const fn new(initial_prefix: &'static str, subsequent_prefix: &'static str) -> Self {
|
||||
Self {
|
||||
initial_prefix,
|
||||
@@ -560,6 +583,7 @@ impl PrefixedBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate available wrap width after accounting for prefix width at the given terminal size.
|
||||
fn wrap_width(self, total_width: u16) -> usize {
|
||||
let prefix_width = UnicodeWidthStr::width(self.initial_prefix)
|
||||
.max(UnicodeWidthStr::width(self.subsequent_prefix));
|
||||
@@ -567,6 +591,7 @@ impl PrefixedBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout knobs for command continuation and output sections.
|
||||
#[derive(Clone, Copy)]
|
||||
struct ExecDisplayLayout {
|
||||
command_continuation: PrefixedBlock,
|
||||
@@ -576,6 +601,7 @@ struct ExecDisplayLayout {
|
||||
}
|
||||
|
||||
impl ExecDisplayLayout {
|
||||
/// Create a layout tying together command/output wrap options for exec rendering.
|
||||
const fn new(
|
||||
command_continuation: PrefixedBlock,
|
||||
command_continuation_max_lines: usize,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
93
codex-rs/tui/src/history_cell/agent.rs
Normal file
93
codex-rs/tui/src/history_cell/agent.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use super::HistoryCell;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Agent-produced lines shown in the scrolling history pane after user input or tool output.
|
||||
///
|
||||
/// Displays assistant replies with a dim bullet on the first line and a hanging indent on wrapped
|
||||
/// lines so finalized responses match the streamed appearance in the transcript view.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// • hello world from the agent reply
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AgentMessageCell {
|
||||
pub(crate) lines: Vec<Line<'static>>,
|
||||
pub(crate) is_first_line: bool,
|
||||
}
|
||||
|
||||
impl AgentMessageCell {
|
||||
/// Create a new agent message block, marking whether this is the first line.
|
||||
///
|
||||
/// `is_first_line` controls whether a bullet is shown; continuations keep the same indent but
|
||||
/// omit the bullet so multi-line answers look like a single paragraph.
|
||||
pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {
|
||||
Self {
|
||||
lines,
|
||||
is_first_line,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for AgentMessageCell {
|
||||
/// Wrap agent text to the available width with a bullet then hanging indent.
|
||||
///
|
||||
/// Uses `word_wrap_lines`, prefixed with a dim bullet on the first line and two-space indent on
|
||||
/// following lines so agent replies are visually grouped in the history list.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
word_wrap_lines(
|
||||
&self.lines,
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent(if self.is_first_line {
|
||||
"• ".dim().into()
|
||||
} else {
|
||||
" ".into()
|
||||
})
|
||||
.subsequent_indent(" ".into()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Treat follow-up lines as stream continuations so separators are avoided.
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
!self.is_first_line
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn empty_transcript_line() {
|
||||
let cell = AgentMessageCell::new(vec![Line::default()], false);
|
||||
assert_eq!(cell.transcript_lines(80), vec![Line::from(" ")]);
|
||||
assert_eq!(cell.desired_transcript_height(80), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_with_hanging_indent() {
|
||||
let cell = AgentMessageCell::new(
|
||||
vec![Line::from(
|
||||
"Here is how to fix the failing tests by adjusting the mock responses.",
|
||||
)],
|
||||
true,
|
||||
);
|
||||
|
||||
assert_snapshot!(cell.display_string(34));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_line_omits_bullet() {
|
||||
let cell = AgentMessageCell::new(
|
||||
vec![Line::from("Then continue streaming without a new bullet.")],
|
||||
false,
|
||||
);
|
||||
|
||||
assert_snapshot!(cell.display_string(32));
|
||||
}
|
||||
}
|
||||
187
codex-rs/tui/src/history_cell/approval_decision.rs
Normal file
187
codex-rs/tui/src/history_cell/approval_decision.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
use super::HistoryCell;
|
||||
use super::prefixed_wrapped::PrefixedWrappedHistoryCell;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
/// Approval decision banner summarizing how the user responded to a command prompt.
|
||||
///
|
||||
/// Used after Codex prompts for a command approval to show whether the user approved/denied. Renders
|
||||
/// a colored bullet (`✔` for approvals, `✗` for denials/abort) followed by a short sentence with a
|
||||
/// truncated command snippet, wrapped with a hanging indent so multi-line commands stay aligned.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// ✔ You approved codex to run echo hello every time this session
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ApprovalDecisionCell {
|
||||
inner: PrefixedWrappedHistoryCell,
|
||||
}
|
||||
|
||||
/// Build a boxed approval decision entry to slot directly into the history stream.
|
||||
pub(crate) fn new_approval_decision_cell(
|
||||
command: Vec<String>,
|
||||
decision: codex_core::protocol::ReviewDecision,
|
||||
) -> Box<dyn super::HistoryCell> {
|
||||
ApprovalDecisionCell::new_boxed(command, decision)
|
||||
}
|
||||
|
||||
impl ApprovalDecisionCell {
|
||||
pub(crate) fn new_boxed(
|
||||
command: Vec<String>,
|
||||
decision: codex_core::protocol::ReviewDecision,
|
||||
) -> Box<dyn super::HistoryCell> {
|
||||
Box::new(Self::new(command, decision))
|
||||
}
|
||||
|
||||
/// Build a decision cell from the full command and the decision result.
|
||||
///
|
||||
/// Multiline commands are truncated to a single-line snippet with ellipsis and dimmed; wrapped
|
||||
/// lines are prefixed with either the colored bullet or a two-space hanging indent.
|
||||
pub(crate) fn new(
|
||||
command: Vec<String>,
|
||||
decision: codex_core::protocol::ReviewDecision,
|
||||
) -> Self {
|
||||
use codex_core::protocol::ReviewDecision::*;
|
||||
|
||||
let (symbol, summary): (Span<'static>, Vec<Span<'static>>) = match decision {
|
||||
Approved => {
|
||||
let snippet = Span::from(exec_snippet(&command)).dim();
|
||||
(
|
||||
"✔ ".green(),
|
||||
vec![
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
" codex to run ".into(),
|
||||
snippet,
|
||||
" this time".bold(),
|
||||
],
|
||||
)
|
||||
}
|
||||
ApprovedForSession => {
|
||||
let snippet = Span::from(exec_snippet(&command)).dim();
|
||||
(
|
||||
"✔ ".green(),
|
||||
vec![
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
" codex to run ".into(),
|
||||
snippet,
|
||||
" every time this session".bold(),
|
||||
],
|
||||
)
|
||||
}
|
||||
Denied => {
|
||||
let snippet = Span::from(exec_snippet(&command)).dim();
|
||||
(
|
||||
"✗ ".red(),
|
||||
vec![
|
||||
"You ".into(),
|
||||
"did not approve".bold(),
|
||||
" codex to run ".into(),
|
||||
snippet,
|
||||
],
|
||||
)
|
||||
}
|
||||
Abort => {
|
||||
let snippet = Span::from(exec_snippet(&command)).dim();
|
||||
(
|
||||
"✗ ".red(),
|
||||
vec![
|
||||
"You ".into(),
|
||||
"canceled".bold(),
|
||||
" the request to run ".into(),
|
||||
snippet,
|
||||
],
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
inner: PrefixedWrappedHistoryCell::new(Line::from(summary), symbol, " "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for ApprovalDecisionCell {
|
||||
/// Forward to the wrapped `PrefixedWrappedHistoryCell` so rendering is consistent.
|
||||
///
|
||||
/// Shows a colored bullet plus the decision summary, wrapping with a hanging indent to keep long
|
||||
/// commands aligned with their prefix.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
self.inner.display_lines(width)
|
||||
}
|
||||
|
||||
/// Use the wrapped cell’s height calculation.
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.inner.desired_height(width)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim multiline commands down to a single preview line with ellipsis and truncation.
|
||||
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 = crate::text_formatting::truncate_text(&snippet, 80);
|
||||
snippet
|
||||
}
|
||||
|
||||
/// Convert the raw command vector into a displayable snippet for audit banners.
|
||||
fn exec_snippet(command: &[String]) -> String {
|
||||
let full_cmd = strip_bash_lc_and_escape(command);
|
||||
truncate_exec_snippet(&full_cmd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
fn render(decision: codex_core::protocol::ReviewDecision) -> String {
|
||||
let cell = ApprovalDecisionCell::new(
|
||||
vec![
|
||||
"bash".into(),
|
||||
"-lc".into(),
|
||||
"echo checking migration script safety before applying".into(),
|
||||
],
|
||||
decision,
|
||||
);
|
||||
cell.display_string(46)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approved_for_session_shows_every_time_copy() {
|
||||
let rendered = render(codex_core::protocol::ReviewDecision::ApprovedForSession);
|
||||
assert!(rendered.contains("every time this session"));
|
||||
assert!(rendered.starts_with('✔'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_shows_red_cross() {
|
||||
let rendered = render(codex_core::protocol::ReviewDecision::Denied);
|
||||
assert!(rendered.starts_with('✗'));
|
||||
assert!(rendered.contains("did not approve"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_for_each_decision() {
|
||||
assert_snapshot!(
|
||||
"approved",
|
||||
render(codex_core::protocol::ReviewDecision::Approved)
|
||||
);
|
||||
assert_snapshot!(
|
||||
"approved_for_session",
|
||||
render(codex_core::protocol::ReviewDecision::ApprovedForSession)
|
||||
);
|
||||
assert_snapshot!(
|
||||
"denied",
|
||||
render(codex_core::protocol::ReviewDecision::Denied)
|
||||
);
|
||||
assert_snapshot!("abort", render(codex_core::protocol::ReviewDecision::Abort));
|
||||
}
|
||||
}
|
||||
78
codex-rs/tui/src/history_cell/composite.rs
Normal file
78
codex-rs/tui/src/history_cell/composite.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use super::HistoryCell;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Concatenates several child cells into a single history entry.
|
||||
///
|
||||
/// Used for multi-part rows like the session header plus onboarding hints so the history shows a
|
||||
/// single item with intentional spacing. Each child renders with the provided width; non-empty
|
||||
/// parts are joined with a blank line separator to preserve breathing room without the caller
|
||||
/// managing padding manually.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CompositeHistoryCell {
|
||||
pub(crate) parts: Vec<Box<dyn HistoryCell>>,
|
||||
}
|
||||
|
||||
impl CompositeHistoryCell {
|
||||
/// Build a composite from pre-renderable child cells.
|
||||
///
|
||||
/// Empty children are skipped; adjacent non-empty children are separated by a blank line to
|
||||
/// keep their blocks visually distinct while staying inside one history slot.
|
||||
pub(crate) fn new(parts: Vec<Box<dyn HistoryCell>>) -> Self {
|
||||
Self { parts }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for CompositeHistoryCell {
|
||||
/// Render each child and join them with blank lines to form one entry.
|
||||
///
|
||||
/// Children render at the provided width; empty children are elided and non-empty neighbors are
|
||||
/// separated by a single blank line to preserve their padding without extra caller logic.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn joins_non_empty_parts_with_blank_line() {
|
||||
let composite = CompositeHistoryCell::new(vec![
|
||||
Box::new(PlainHistoryCell::new(vec![Line::from("first")])),
|
||||
Box::new(PlainHistoryCell::new(Vec::new())),
|
||||
Box::new(PlainHistoryCell::new(vec![Line::from("second")])),
|
||||
]);
|
||||
|
||||
assert_eq!(composite.display_string(80), "first\n\nsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_with_wrapped_children() {
|
||||
let composite = CompositeHistoryCell::new(vec![
|
||||
Box::new(PlainHistoryCell::new(vec![Line::from(
|
||||
"Session header: OpenAI Codex (v1.2)",
|
||||
)])),
|
||||
Box::new(PlainHistoryCell::new(vec![Line::from(
|
||||
"Help: Press ? to see keyboard shortcuts for navigating history.",
|
||||
)])),
|
||||
]);
|
||||
|
||||
assert_snapshot!(composite.display_string(48));
|
||||
}
|
||||
}
|
||||
81
codex-rs/tui/src/history_cell/deprecation.rs
Normal file
81
codex-rs/tui/src/history_cell/deprecation.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use super::HistoryCell;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Alert that a feature or flag is deprecated.
|
||||
///
|
||||
/// Renders a red warning line and optional wrapped detail text so users can migrate without digging
|
||||
/// into logs. Used for CLI warnings/informational notices in the history panel.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// ⚠ Feature flag `foo`
|
||||
/// Use flag `bar` instead.
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DeprecationNoticeCell {
|
||||
summary: String,
|
||||
details: Option<String>,
|
||||
}
|
||||
|
||||
impl DeprecationNoticeCell {
|
||||
pub(crate) fn new(summary: String, details: Option<String>) -> Self {
|
||||
Self { summary, details }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_deprecation_notice(
|
||||
summary: String,
|
||||
details: Option<String>,
|
||||
) -> DeprecationNoticeCell {
|
||||
DeprecationNoticeCell::new(summary, details)
|
||||
}
|
||||
|
||||
impl HistoryCell for DeprecationNoticeCell {
|
||||
/// Render the summary in red with an optional wrapped detail following on subsequent lines.
|
||||
///
|
||||
/// Uses a bold red `⚠` prefix and summary on the first line, then wraps `details` at
|
||||
/// `width-4` with dim styling so follow-up context is readable but secondary.
|
||||
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 line = textwrap::wrap(details, wrap_width)
|
||||
.into_iter()
|
||||
.map(|s| s.to_string().dim().into())
|
||||
.collect::<Vec<_>>();
|
||||
lines.extend(line);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn deprecation_with_details_wraps() {
|
||||
let cell = DeprecationNoticeCell::new(
|
||||
"Feature flag `foo`".to_string(),
|
||||
Some("Use flag `bar` instead of relying on implicit defaults.".to_string()),
|
||||
);
|
||||
|
||||
let rendered = cell.display_string(32);
|
||||
assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_summary_only_when_no_details() {
|
||||
let cell = DeprecationNoticeCell::new("Old endpoint deprecated".to_string(), None);
|
||||
let rendered = cell.display_string(40);
|
||||
|
||||
assert_snapshot!(rendered);
|
||||
}
|
||||
}
|
||||
348
codex-rs/tui/src/history_cell/exec.rs
Normal file
348
codex-rs/tui/src/history_cell/exec.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
//! Execution history cell docs and tests.
|
||||
//!
|
||||
//! The rendering itself lives in `exec_cell`, but the History Cell refactor keeps the tests for
|
||||
//! this cell co-located with the other history cell modules and documents what the execution cell
|
||||
//! is expected to show.
|
||||
//!
|
||||
//! # Output
|
||||
//!
|
||||
//! ```plain
|
||||
//! • Ran bash -lc echo ok
|
||||
//! └ (no output)
|
||||
//! ```
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::exec_cell::CommandOutput;
|
||||
use crate::exec_cell::ExecCall;
|
||||
use crate::exec_cell::ExecCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use codex_core::protocol::ExecCommandSource;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
#[test]
|
||||
fn coalesces_sequential_reads_within_one_call() {
|
||||
// Build one exec cell with a Search followed by two Reads
|
||||
let call_id = "c1".to_string();
|
||||
let mut cell = ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo".into()],
|
||||
parsed: vec![
|
||||
ParsedCommand::Search {
|
||||
query: Some("shimmer_spans".into()),
|
||||
path: None,
|
||||
cmd: "rg shimmer_spans".into(),
|
||||
},
|
||||
ParsedCommand::Read {
|
||||
name: "shimmer.rs".into(),
|
||||
cmd: "cat shimmer.rs".into(),
|
||||
path: "shimmer.rs".into(),
|
||||
},
|
||||
ParsedCommand::Read {
|
||||
name: "status_indicator_widget.rs".into(),
|
||||
cmd: "cat status_indicator_widget.rs".into(),
|
||||
path: "status_indicator_widget.rs".into(),
|
||||
},
|
||||
],
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
// Mark call complete so markers are ✓
|
||||
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
|
||||
|
||||
let rendered = cell.display_string(80);
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesces_reads_across_multiple_calls() {
|
||||
let mut cell = ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: "c1".to_string(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo".into()],
|
||||
parsed: vec![ParsedCommand::Search {
|
||||
query: Some("shimmer_spans".into()),
|
||||
path: None,
|
||||
cmd: "rg shimmer_spans".into(),
|
||||
}],
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
// Call 1: Search only
|
||||
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
|
||||
// Call 2: Read A
|
||||
cell = cell
|
||||
.with_added_call(
|
||||
"c2".into(),
|
||||
vec!["bash".into(), "-lc".into(), "echo".into()],
|
||||
vec![ParsedCommand::Read {
|
||||
name: "shimmer.rs".into(),
|
||||
cmd: "cat shimmer.rs".into(),
|
||||
path: "shimmer.rs".into(),
|
||||
}],
|
||||
ExecCommandSource::Agent,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
cell.complete_call("c2", CommandOutput::default(), Duration::from_millis(1));
|
||||
// Call 3: Read B
|
||||
cell = cell
|
||||
.with_added_call(
|
||||
"c3".into(),
|
||||
vec!["bash".into(), "-lc".into(), "echo".into()],
|
||||
vec![ParsedCommand::Read {
|
||||
name: "status_indicator_widget.rs".into(),
|
||||
cmd: "cat status_indicator_widget.rs".into(),
|
||||
path: "status_indicator_widget.rs".into(),
|
||||
}],
|
||||
ExecCommandSource::Agent,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
cell.complete_call("c3", CommandOutput::default(), Duration::from_millis(1));
|
||||
|
||||
let rendered = cell.display_string(80);
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_reads_dedupe_names() {
|
||||
let mut cell = ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: "c1".to_string(),
|
||||
command: vec!["bash".into(), "-lc".into(), "echo".into()],
|
||||
parsed: vec![
|
||||
ParsedCommand::Read {
|
||||
name: "auth.rs".into(),
|
||||
cmd: "cat auth.rs".into(),
|
||||
path: "auth.rs".into(),
|
||||
},
|
||||
ParsedCommand::Read {
|
||||
name: "auth.rs".into(),
|
||||
cmd: "cat auth.rs".into(),
|
||||
path: "auth.rs".into(),
|
||||
},
|
||||
ParsedCommand::Read {
|
||||
name: "shimmer.rs".into(),
|
||||
cmd: "cat shimmer.rs".into(),
|
||||
path: "shimmer.rs".into(),
|
||||
},
|
||||
],
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
cell.complete_call("c1", CommandOutput::default(), Duration::from_millis(1));
|
||||
let rendered = cell.display_string(80);
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_command_wraps_with_extra_indent_on_subsequent_lines() {
|
||||
// Create a completed exec cell with a multiline command
|
||||
let cmd = "set -o pipefail\ncargo test --all-features --quiet".to_string();
|
||||
let call_id = "c1".to_string();
|
||||
let mut cell = ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), cmd],
|
||||
parsed: Vec::new(),
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
// Mark call complete so it renders as "Ran"
|
||||
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
|
||||
|
||||
// Small width to force wrapping on both lines
|
||||
let width: u16 = 28;
|
||||
let rendered = cell.display_string(width);
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_command_compact_when_fits() {
|
||||
let call_id = "c1".to_string();
|
||||
let mut cell = ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["echo".into(), "ok".into()],
|
||||
parsed: Vec::new(),
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
|
||||
// Wide enough that it fits inline
|
||||
let rendered = cell.display_string(80);
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_command_wraps_with_four_space_continuation() {
|
||||
let call_id = "c1".to_string();
|
||||
let long = "a_very_long_token_without_spaces_to_force_wrapping".to_string();
|
||||
let mut cell = ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), long],
|
||||
parsed: Vec::new(),
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
|
||||
let rendered = cell.display_string(24);
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_command_without_wrap_uses_branch_then_eight_spaces() {
|
||||
let call_id = "c1".to_string();
|
||||
let cmd = "echo one\necho two".to_string();
|
||||
let mut cell = ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), cmd],
|
||||
parsed: Vec::new(),
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
|
||||
let rendered = cell.display_string(80);
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_command_both_lines_wrap_with_correct_prefixes() {
|
||||
let call_id = "c1".to_string();
|
||||
let cmd = "first_token_is_long_enough_to_wrap\nsecond_token_is_also_long_enough_to_wrap"
|
||||
.to_string();
|
||||
let mut cell = ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), cmd],
|
||||
parsed: Vec::new(),
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
cell.complete_call(&call_id, CommandOutput::default(), Duration::from_millis(1));
|
||||
let rendered = cell.display_string(28);
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stderr_tail_more_than_five_lines_snapshot() {
|
||||
// Build an exec cell with a non-zero exit and 10 lines on stderr to exercise
|
||||
// the head/tail rendering and gutter prefixes.
|
||||
let call_id = "c_err".to_string();
|
||||
let mut cell = ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), "seq 1 10 1>&2 && false".into()],
|
||||
parsed: Vec::new(),
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
let stderr: String = (1..=10)
|
||||
.map(|n| n.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
cell.complete_call(
|
||||
&call_id,
|
||||
CommandOutput {
|
||||
exit_code: 1,
|
||||
formatted_output: String::new(),
|
||||
aggregated_output: stderr,
|
||||
},
|
||||
Duration::from_millis(1),
|
||||
);
|
||||
|
||||
let rendered = cell.display_string(80);
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ran_cell_multiline_with_stderr_snapshot() {
|
||||
// Build an exec cell that completes (so it renders as "Ran") with a
|
||||
// command long enough that it must render on its own line under the
|
||||
// header, and include a couple of stderr lines to verify the output
|
||||
// block prefixes and wrapping.
|
||||
let call_id = "c_wrap_err".to_string();
|
||||
let long_cmd =
|
||||
"echo this_is_a_very_long_single_token_that_will_wrap_across_the_available_width";
|
||||
let mut cell = ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: call_id.clone(),
|
||||
command: vec!["bash".into(), "-lc".into(), long_cmd.to_string()],
|
||||
parsed: Vec::new(),
|
||||
output: None,
|
||||
source: ExecCommandSource::Agent,
|
||||
start_time: Some(Instant::now()),
|
||||
duration: None,
|
||||
interaction_input: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
let stderr = "error: first line on stderr\nerror: second line on stderr".to_string();
|
||||
cell.complete_call(
|
||||
&call_id,
|
||||
CommandOutput {
|
||||
exit_code: 1,
|
||||
formatted_output: String::new(),
|
||||
aggregated_output: stderr,
|
||||
},
|
||||
Duration::from_millis(5),
|
||||
);
|
||||
|
||||
// Narrow width to force the command to render under the header line.
|
||||
let width: u16 = 28;
|
||||
let rendered = cell.display_string(width);
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
}
|
||||
85
codex-rs/tui/src/history_cell/final_separator.rs
Normal file
85
codex-rs/tui/src/history_cell/final_separator.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use super::HistoryCell;
|
||||
use crate::status_indicator_widget;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Divider shown before the final agent message.
|
||||
///
|
||||
/// Draws a horizontal rule and optionally appends a compact elapsed-time summary to show how long
|
||||
/// the session ran. Used at the end of the history transcript to separate the concluding response
|
||||
/// from prior activity.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// ─ Worked for 2m14s ──────
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct FinalMessageSeparator {
|
||||
elapsed_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
impl FinalMessageSeparator {
|
||||
pub(crate) fn new(elapsed_seconds: Option<u64>) -> Self {
|
||||
Self { elapsed_seconds }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for FinalMessageSeparator {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let elapsed_seconds = self
|
||||
.elapsed_seconds
|
||||
.map(status_indicator_widget::fmt_elapsed_compact);
|
||||
if let Some(elapsed_seconds) = elapsed_seconds {
|
||||
let worked_for = format!("─ Worked for {elapsed_seconds} ─");
|
||||
let worked_for_width = worked_for.width();
|
||||
vec![
|
||||
Line::from_iter([
|
||||
worked_for,
|
||||
"─".repeat((width as usize).saturating_sub(worked_for_width)),
|
||||
])
|
||||
.dim(),
|
||||
]
|
||||
} else {
|
||||
vec![Line::from_iter(["─".repeat(width as usize).dim()])]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn renders_with_elapsed() {
|
||||
let sep = FinalMessageSeparator::new(Some(134));
|
||||
let rendered = sep.display_string(32);
|
||||
|
||||
assert!(rendered.contains("Worked for"));
|
||||
assert!(rendered.starts_with('─'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_simple_rule_when_no_elapsed() {
|
||||
let sep = FinalMessageSeparator::new(None);
|
||||
let rendered = sep.display_string(20);
|
||||
|
||||
assert_eq!(rendered, "────────────────────");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separator_with_elapsed() {
|
||||
let sep = FinalMessageSeparator::new(Some(372));
|
||||
|
||||
assert_snapshot!(sep.display_string(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separator_without_elapsed() {
|
||||
let sep = FinalMessageSeparator::new(None);
|
||||
|
||||
assert_snapshot!(sep.display_string(34));
|
||||
}
|
||||
}
|
||||
824
codex-rs/tui/src/history_cell/mcp.rs
Normal file
824
codex-rs/tui/src/history_cell/mcp.rs
Normal file
@@ -0,0 +1,824 @@
|
||||
use super::HistoryCell;
|
||||
use crate::exec_cell::TOOL_CALL_MAX_LINES;
|
||||
use crate::exec_cell::spinner;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::text_formatting::format_and_truncate_tool_result;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
use base64::Engine;
|
||||
use codex_common::format_env_display::format_env_display;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_core::protocol::McpAuthStatus;
|
||||
use codex_core::protocol::McpInvocation;
|
||||
use image::DynamicImage;
|
||||
use image::ImageReader;
|
||||
use mcp_types::EmbeddedResourceResource;
|
||||
use mcp_types::Resource;
|
||||
use mcp_types::ResourceLink;
|
||||
use mcp_types::ResourceTemplate;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tracing::error;
|
||||
|
||||
/// Summary of MCP tools and resources configured for the session.
|
||||
///
|
||||
/// Renders a `/mcp` section header, connection metadata, and tool/resource listings grouped by
|
||||
/// server. Sensitive values such as env vars and headers are masked.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// /mcp
|
||||
///
|
||||
/// 🔌 MCP Tools
|
||||
///
|
||||
/// • docs
|
||||
/// • Status: enabled
|
||||
/// • Auth: supported
|
||||
/// • Command: docs-server
|
||||
/// • Tools: list
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct McpToolsOutputCell {
|
||||
lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
impl McpToolsOutputCell {
|
||||
pub(crate) fn new(
|
||||
config: &Config,
|
||||
tools: HashMap<String, mcp_types::Tool>,
|
||||
resources: HashMap<String, Vec<Resource>>,
|
||||
resource_templates: HashMap<String, Vec<ResourceTemplate>>,
|
||||
auth_statuses: &HashMap<String, McpAuthStatus>,
|
||||
) -> Self {
|
||||
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());
|
||||
return Self { lines };
|
||||
}
|
||||
|
||||
let mut servers: Vec<_> = config.mcp_servers.iter().collect();
|
||||
servers.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
|
||||
for (server, cfg) in servers {
|
||||
let prefix = format!("mcp__{server}__");
|
||||
let mut names: Vec<String> = tools
|
||||
.keys()
|
||||
.filter(|k| k.starts_with(&prefix))
|
||||
.map(|k| k[prefix.len()..].to_string())
|
||||
.collect();
|
||||
names.sort();
|
||||
|
||||
let auth_status = auth_statuses
|
||||
.get(server.as_str())
|
||||
.copied()
|
||||
.unwrap_or(McpAuthStatus::Unsupported);
|
||||
let mut header: Vec<Span<'static>> = vec![" • ".into(), server.clone().into()];
|
||||
if !cfg.enabled {
|
||||
header.push(" ".into());
|
||||
header.push("(disabled)".red());
|
||||
lines.push(header.into());
|
||||
lines.push(Line::from(""));
|
||||
continue;
|
||||
}
|
||||
lines.push(header.into());
|
||||
lines.push(vec![" • Status: ".into(), "enabled".green()].into());
|
||||
lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into());
|
||||
|
||||
match &cfg.transport {
|
||||
McpServerTransportConfig::Stdio {
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
env_vars,
|
||||
cwd,
|
||||
} => {
|
||||
let args_suffix = if args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", args.join(" "))
|
||||
};
|
||||
let cmd_display = format!("{command}{args_suffix}");
|
||||
lines.push(vec![" • Command: ".into(), cmd_display.into()].into());
|
||||
|
||||
if let Some(cwd) = cwd.as_ref() {
|
||||
lines.push(
|
||||
vec![" • Cwd: ".into(), cwd.display().to_string().into()].into(),
|
||||
);
|
||||
}
|
||||
|
||||
let env_display = format_env_display(env.as_ref(), env_vars);
|
||||
if env_display != "-" {
|
||||
lines.push(vec![" • Env: ".into(), env_display.into()].into());
|
||||
}
|
||||
}
|
||||
McpServerTransportConfig::StreamableHttp {
|
||||
url,
|
||||
http_headers,
|
||||
env_http_headers,
|
||||
..
|
||||
} => {
|
||||
lines.push(vec![" • URL: ".into(), url.clone().into()].into());
|
||||
if let Some(headers) = http_headers.as_ref()
|
||||
&& !headers.is_empty()
|
||||
{
|
||||
let mut pairs: Vec<_> = headers.iter().collect();
|
||||
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
let display = pairs
|
||||
.into_iter()
|
||||
.map(|(name, _)| format!("{name}=*****"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
lines.push(vec![" • HTTP headers: ".into(), display.into()].into());
|
||||
}
|
||||
if let Some(headers) = env_http_headers.as_ref()
|
||||
&& !headers.is_empty()
|
||||
{
|
||||
let mut pairs: Vec<_> = headers.iter().collect();
|
||||
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
let display = pairs
|
||||
.into_iter()
|
||||
.map(|(name, var)| format!("{name}={var}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
lines.push(vec![" • Env HTTP headers: ".into(), display.into()].into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if names.is_empty() {
|
||||
lines.push(" • Tools: (none)".into());
|
||||
} else {
|
||||
lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into());
|
||||
}
|
||||
|
||||
let server_resources: Vec<Resource> =
|
||||
resources.get(server.as_str()).cloned().unwrap_or_default();
|
||||
if server_resources.is_empty() {
|
||||
lines.push(" • Resources: (none)".into());
|
||||
} else {
|
||||
let mut spans: Vec<Span<'static>> = vec![" • Resources: ".into()];
|
||||
|
||||
for (idx, resource) in server_resources.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
spans.push(", ".into());
|
||||
}
|
||||
|
||||
let label = resource.title.as_ref().unwrap_or(&resource.name);
|
||||
spans.push(label.clone().into());
|
||||
spans.push(" ".into());
|
||||
spans.push(format!("({})", resource.uri).dim());
|
||||
}
|
||||
|
||||
lines.push(spans.into());
|
||||
}
|
||||
|
||||
let server_templates: Vec<ResourceTemplate> = resource_templates
|
||||
.get(server.as_str())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if server_templates.is_empty() {
|
||||
lines.push(" • Resource templates: (none)".into());
|
||||
} else {
|
||||
let mut spans: Vec<Span<'static>> = vec![" • Resource templates: ".into()];
|
||||
|
||||
for (idx, template) in server_templates.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
spans.push(", ".into());
|
||||
}
|
||||
|
||||
let label = template.title.as_ref().unwrap_or(&template.name);
|
||||
spans.push(label.clone().into());
|
||||
spans.push(" ".into());
|
||||
spans.push(format!("({})", template.uri_template).dim());
|
||||
}
|
||||
|
||||
lines.push(spans.into());
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
Self { lines }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for McpToolsOutputCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.lines.clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_mcp_tools_output(
|
||||
config: &Config,
|
||||
tools: HashMap<String, mcp_types::Tool>,
|
||||
resources: HashMap<String, Vec<Resource>>,
|
||||
resource_templates: HashMap<String, Vec<ResourceTemplate>>,
|
||||
auth_statuses: &HashMap<String, McpAuthStatus>,
|
||||
) -> McpToolsOutputCell {
|
||||
McpToolsOutputCell::new(config, tools, resources, resource_templates, auth_statuses)
|
||||
}
|
||||
|
||||
pub(crate) fn empty_mcp_output() -> McpToolsOutputCell {
|
||||
McpToolsOutputCell {
|
||||
lines: {
|
||||
let mut link_line: Line = vec![
|
||||
" See the ".into(),
|
||||
"\u{1b}]8;;https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(),
|
||||
" to configure them.".into(),
|
||||
]
|
||||
.into();
|
||||
link_line = link_line.patch_style(Style::default().add_modifier(Modifier::DIM));
|
||||
|
||||
vec![
|
||||
"/mcp".magenta().into(),
|
||||
"".into(),
|
||||
vec!["🔌 ".into(), "MCP Tools".bold()].into(),
|
||||
"".into(),
|
||||
" • No MCP servers configured.".italic().into(),
|
||||
link_line,
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Status line for active or completed MCP tool calls.
|
||||
///
|
||||
/// Shows a spinner or success/error bullet, the invoked server.tool signature, and wrapped snippets
|
||||
/// of any returned text output. Used for both in-progress calls and final results.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// • Calling search.find_docs({"query":"q"})
|
||||
/// └ <wrapped output>
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct McpToolCallCell {
|
||||
call_id: String,
|
||||
invocation: McpInvocation,
|
||||
start_time: Instant,
|
||||
duration: Option<Duration>,
|
||||
result: Option<Result<mcp_types::CallToolResult, String>>,
|
||||
animations_enabled: bool,
|
||||
}
|
||||
|
||||
impl McpToolCallCell {
|
||||
pub(crate) fn new(
|
||||
call_id: String,
|
||||
invocation: McpInvocation,
|
||||
animations_enabled: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
call_id,
|
||||
invocation,
|
||||
start_time: Instant::now(),
|
||||
duration: None,
|
||||
result: None,
|
||||
animations_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn call_id(&self) -> &str {
|
||||
&self.call_id
|
||||
}
|
||||
|
||||
pub(crate) fn complete(
|
||||
&mut self,
|
||||
duration: Duration,
|
||||
result: Result<mcp_types::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
|
||||
}
|
||||
|
||||
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 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,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_content_block(block: &mcp_types::ContentBlock, width: usize) -> String {
|
||||
match block {
|
||||
mcp_types::ContentBlock::TextContent(text) => {
|
||||
format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width)
|
||||
}
|
||||
mcp_types::ContentBlock::ImageContent(_) => "<image content>".to_string(),
|
||||
mcp_types::ContentBlock::AudioContent(_) => "<audio content>".to_string(),
|
||||
mcp_types::ContentBlock::EmbeddedResource(resource) => {
|
||||
let uri = match &resource.resource {
|
||||
EmbeddedResourceResource::TextResourceContents(text) => text.uri.clone(),
|
||||
EmbeddedResourceResource::BlobResourceContents(blob) => blob.uri.clone(),
|
||||
};
|
||||
format!("embedded resource: {uri}")
|
||||
}
|
||||
mcp_types::ContentBlock::ResourceLink(ResourceLink { uri, .. }) => {
|
||||
format!("link: {uri}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for McpToolCallCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let status = self.success();
|
||||
let bullet = match status {
|
||||
Some(true) => "•".green().bold(),
|
||||
Some(false) => "•".red().bold(),
|
||||
None => spinner(Some(self.start_time), self.animations_enabled),
|
||||
};
|
||||
let header_text = if status.is_some() {
|
||||
"Called"
|
||||
} else {
|
||||
"Calling"
|
||||
};
|
||||
|
||||
let invocation_line = line_to_static(&format_mcp_invocation(self.invocation.clone()));
|
||||
let mut compact_spans = vec![bullet.clone(), " ".into(), header_text.bold(), " ".into()];
|
||||
let mut compact_header = Line::from(compact_spans.clone());
|
||||
let reserved = compact_header.width();
|
||||
|
||||
let inline_invocation =
|
||||
invocation_line.width() <= (width as usize).saturating_sub(reserved);
|
||||
|
||||
if inline_invocation {
|
||||
compact_header.extend(invocation_line.spans.clone());
|
||||
lines.push(compact_header);
|
||||
} else {
|
||||
compact_spans.pop(); // drop trailing space for standalone header
|
||||
lines.push(Line::from(compact_spans));
|
||||
|
||||
let opts = RtOptions::new((width as usize).saturating_sub(4))
|
||||
.initial_indent("".into())
|
||||
.subsequent_indent(" ".into());
|
||||
let wrapped = word_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(mcp_types::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 = word_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 = word_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
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_active_mcp_tool_call(
|
||||
call_id: String,
|
||||
invocation: McpInvocation,
|
||||
animations_enabled: bool,
|
||||
) -> McpToolCallCell {
|
||||
McpToolCallCell::new(call_id, invocation, animations_enabled)
|
||||
}
|
||||
|
||||
/// Placeholder cell for MCP tool calls that yielded an image.
|
||||
///
|
||||
/// The image itself is handled elsewhere; this cell keeps the history entry consistent while image
|
||||
/// rendering support is implemented.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CompletedMcpToolCallWithImageOutput {
|
||||
_image: DynamicImage,
|
||||
}
|
||||
|
||||
impl HistoryCell for CompletedMcpToolCallWithImageOutput {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
vec!["tool result (image output)".into()]
|
||||
}
|
||||
}
|
||||
|
||||
/// If the first content is an image, return a new cell with the image.
|
||||
/// TODO(rgwood-dd): Handle images properly even if they're not the first result.
|
||||
fn try_new_completed_mcp_tool_call_with_image_output(
|
||||
result: &Result<mcp_types::CallToolResult, String>,
|
||||
) -> Option<CompletedMcpToolCallWithImageOutput> {
|
||||
match result {
|
||||
Ok(mcp_types::CallToolResult { content, .. }) => {
|
||||
if let Some(mcp_types::ContentBlock::ImageContent(image)) = content.first() {
|
||||
let raw_data = match base64::engine::general_purpose::STANDARD.decode(&image.data) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
error!("Failed to decode image data: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let reader = match ImageReader::new(Cursor::new(raw_data)).with_guessed_format() {
|
||||
Ok(reader) => reader,
|
||||
Err(e) => {
|
||||
error!("Failed to guess image format: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let image = match reader.decode() {
|
||||
Ok(image) => image,
|
||||
Err(e) => {
|
||||
error!("Image decoding failed: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(CompletedMcpToolCallWithImageOutput { _image: image })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
|
||||
let args_str = invocation
|
||||
.arguments
|
||||
.as_ref()
|
||||
.map(|v: &serde_json::Value| {
|
||||
// Use compact form to keep things short but readable.
|
||||
serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let invocation_spans = vec![
|
||||
invocation.server.clone().cyan(),
|
||||
".".into(),
|
||||
invocation.tool.cyan(),
|
||||
"(".into(),
|
||||
args_str.dim(),
|
||||
")".into(),
|
||||
];
|
||||
invocation_spans.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::history_cell::new_mcp_tools_output;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::config::types::McpServerConfig;
|
||||
use codex_core::config::types::McpServerTransportConfig;
|
||||
use codex_core::protocol::McpAuthStatus;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::ContentBlock;
|
||||
use mcp_types::TextContent;
|
||||
use mcp_types::Tool;
|
||||
use mcp_types::ToolInputSchema;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
|
||||
fn test_config() -> Config {
|
||||
Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
std::env::temp_dir(),
|
||||
)
|
||||
.expect("config")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_output_masks_sensitive_values() {
|
||||
let mut config = test_config();
|
||||
let mut env = std::collections::HashMap::new();
|
||||
env.insert("TOKEN".to_string(), "secret".to_string());
|
||||
let stdio_config = McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: "docs-server".to_string(),
|
||||
args: vec![],
|
||||
env: Some(env),
|
||||
env_vars: vec!["APP_TOKEN".to_string()],
|
||||
cwd: None,
|
||||
},
|
||||
enabled: true,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
};
|
||||
config.mcp_servers.insert("docs".to_string(), stdio_config);
|
||||
|
||||
let mut headers = std::collections::HashMap::new();
|
||||
headers.insert("Authorization".to_string(), "Bearer secret".to_string());
|
||||
let mut env_headers = std::collections::HashMap::new();
|
||||
env_headers.insert("X-API-Key".to_string(), "API_KEY_ENV".to_string());
|
||||
let http_config = McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
bearer_token_env_var: Some("MCP_TOKEN".to_string()),
|
||||
http_headers: Some(headers),
|
||||
env_http_headers: Some(env_headers),
|
||||
},
|
||||
enabled: true,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
};
|
||||
config.mcp_servers.insert("http".to_string(), http_config);
|
||||
|
||||
let mut tools: std::collections::HashMap<String, Tool> = std::collections::HashMap::new();
|
||||
tools.insert(
|
||||
"mcp__docs__list".to_string(),
|
||||
Tool {
|
||||
annotations: None,
|
||||
description: None,
|
||||
input_schema: ToolInputSchema {
|
||||
properties: None,
|
||||
required: None,
|
||||
r#type: "object".to_string(),
|
||||
},
|
||||
name: "list".to_string(),
|
||||
output_schema: None,
|
||||
title: None,
|
||||
},
|
||||
);
|
||||
tools.insert(
|
||||
"mcp__http__ping".to_string(),
|
||||
Tool {
|
||||
annotations: None,
|
||||
description: None,
|
||||
input_schema: ToolInputSchema {
|
||||
properties: None,
|
||||
required: None,
|
||||
r#type: "object".to_string(),
|
||||
},
|
||||
name: "ping".to_string(),
|
||||
output_schema: None,
|
||||
title: None,
|
||||
},
|
||||
);
|
||||
|
||||
let auth_statuses: std::collections::HashMap<String, McpAuthStatus> =
|
||||
std::collections::HashMap::new();
|
||||
let cell = new_mcp_tools_output(
|
||||
&config,
|
||||
tools,
|
||||
std::collections::HashMap::new(),
|
||||
std::collections::HashMap::new(),
|
||||
&auth_statuses,
|
||||
);
|
||||
let rendered = cell.display_string(120);
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_call_snapshot() {
|
||||
let invocation = McpInvocation {
|
||||
server: "search".into(),
|
||||
tool: "find_docs".into(),
|
||||
arguments: Some(json!({
|
||||
"query": "ratatui styling",
|
||||
"limit": 3,
|
||||
})),
|
||||
};
|
||||
|
||||
let cell = new_active_mcp_tool_call("call-1".into(), invocation, true);
|
||||
let rendered = cell.display_string(80);
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_call_success_snapshot() {
|
||||
let invocation = McpInvocation {
|
||||
server: "search".into(),
|
||||
tool: "find_docs".into(),
|
||||
arguments: Some(json!({
|
||||
"query": "ratatui styling",
|
||||
"limit": 3,
|
||||
})),
|
||||
};
|
||||
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
annotations: None,
|
||||
text: "Found styling guidance in styles.md".into(),
|
||||
r#type: "text".into(),
|
||||
})],
|
||||
is_error: None,
|
||||
structured_content: None,
|
||||
};
|
||||
|
||||
let mut cell = new_active_mcp_tool_call("call-2".into(), invocation, true);
|
||||
assert!(
|
||||
cell.complete(Duration::from_millis(1420), Ok(result))
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let rendered = cell.display_string(80);
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_call_error_snapshot() {
|
||||
let invocation = McpInvocation {
|
||||
server: "search".into(),
|
||||
tool: "find_docs".into(),
|
||||
arguments: Some(json!({
|
||||
"query": "ratatui styling",
|
||||
"limit": 3,
|
||||
})),
|
||||
};
|
||||
|
||||
let mut cell = new_active_mcp_tool_call("call-3".into(), invocation, true);
|
||||
assert!(
|
||||
cell.complete(Duration::from_secs(2), Err("network timeout".into()))
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let rendered = cell.display_string(80);
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_call_multiple_outputs_snapshot() {
|
||||
let invocation = McpInvocation {
|
||||
server: "search".into(),
|
||||
tool: "find_docs".into(),
|
||||
arguments: Some(json!({
|
||||
"query": "ratatui styling",
|
||||
"limit": 3,
|
||||
})),
|
||||
};
|
||||
|
||||
let result = CallToolResult {
|
||||
content: vec![
|
||||
ContentBlock::TextContent(TextContent {
|
||||
annotations: None,
|
||||
text: "Found styling guidance in styles.md and additional notes in CONTRIBUTING.md.".into(),
|
||||
r#type: "text".into(),
|
||||
}),
|
||||
ContentBlock::ResourceLink(ResourceLink {
|
||||
annotations: None,
|
||||
description: Some("Link to styles documentation".into()),
|
||||
mime_type: None,
|
||||
name: "styles.md".into(),
|
||||
size: None,
|
||||
title: Some("Styles".into()),
|
||||
r#type: "resource_link".into(),
|
||||
uri: "file:///docs/styles.md".into(),
|
||||
}),
|
||||
],
|
||||
is_error: None,
|
||||
structured_content: None,
|
||||
};
|
||||
|
||||
let mut cell = new_active_mcp_tool_call("call-4".into(), invocation, true);
|
||||
assert!(
|
||||
cell.complete(Duration::from_millis(640), Ok(result))
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let rendered = cell.display_string(48);
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_call_wrapped_outputs_snapshot() {
|
||||
let invocation = McpInvocation {
|
||||
server: "metrics".into(),
|
||||
tool: "get_nearby_metric".into(),
|
||||
arguments: Some(json!({
|
||||
"query": "very_long_query_that_needs_wrapping_to_display_properly_in_the_history",
|
||||
"limit": 1,
|
||||
})),
|
||||
};
|
||||
|
||||
let result = CallToolResult {
|
||||
content: vec![ContentBlock::TextContent(TextContent {
|
||||
annotations: None,
|
||||
text: "Line one of the response, which is quite long and needs wrapping.\nLine two continues the response with more detail.".into(),
|
||||
r#type: "text".into(),
|
||||
})],
|
||||
is_error: None,
|
||||
structured_content: None,
|
||||
};
|
||||
|
||||
let mut cell = new_active_mcp_tool_call("call-5".into(), invocation, true);
|
||||
assert!(
|
||||
cell.complete(Duration::from_millis(1280), Ok(result))
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let rendered = cell.display_string(40);
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_call_multiple_outputs_inline_snapshot() {
|
||||
let invocation = McpInvocation {
|
||||
server: "metrics".into(),
|
||||
tool: "summary".into(),
|
||||
arguments: Some(json!({
|
||||
"metric": "trace.latency",
|
||||
"window": "15m",
|
||||
})),
|
||||
};
|
||||
|
||||
let result = CallToolResult {
|
||||
content: vec![
|
||||
ContentBlock::TextContent(TextContent {
|
||||
annotations: None,
|
||||
text: "Latency summary: p50=120ms, p95=480ms.".into(),
|
||||
r#type: "text".into(),
|
||||
}),
|
||||
ContentBlock::TextContent(TextContent {
|
||||
annotations: None,
|
||||
text: "No anomalies detected.".into(),
|
||||
r#type: "text".into(),
|
||||
}),
|
||||
],
|
||||
is_error: None,
|
||||
structured_content: None,
|
||||
};
|
||||
|
||||
let mut cell = new_active_mcp_tool_call("call-6".into(), invocation, true);
|
||||
assert!(
|
||||
cell.complete(Duration::from_millis(320), Ok(result))
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let rendered = cell.display_string(120);
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
}
|
||||
269
codex-rs/tui/src/history_cell/mod.rs
Normal file
269
codex-rs/tui/src/history_cell/mod.rs
Normal file
@@ -0,0 +1,269 @@
|
||||
use crate::render::renderable::Renderable;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Wrap;
|
||||
use std::any::Any;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
mod agent;
|
||||
mod approval_decision;
|
||||
mod composite;
|
||||
mod deprecation;
|
||||
mod exec;
|
||||
mod final_separator;
|
||||
mod mcp;
|
||||
mod notice;
|
||||
mod patch;
|
||||
mod patch_apply_failure;
|
||||
mod plain;
|
||||
mod plan;
|
||||
mod prefixed_wrapped;
|
||||
mod reasoning_summary;
|
||||
mod review_status;
|
||||
mod session;
|
||||
mod update_available;
|
||||
mod user;
|
||||
mod view_image;
|
||||
mod web_search;
|
||||
|
||||
pub(crate) use agent::AgentMessageCell;
|
||||
#[expect(unused_imports)]
|
||||
pub(crate) use approval_decision::ApprovalDecisionCell;
|
||||
pub(crate) use approval_decision::new_approval_decision_cell;
|
||||
pub(crate) use composite::CompositeHistoryCell;
|
||||
pub(crate) use deprecation::new_deprecation_notice;
|
||||
pub(crate) use final_separator::FinalMessageSeparator;
|
||||
pub(crate) use mcp::McpToolCallCell;
|
||||
#[expect(unused_imports)]
|
||||
pub(crate) use mcp::McpToolsOutputCell;
|
||||
pub(crate) use mcp::empty_mcp_output;
|
||||
pub(crate) use mcp::new_active_mcp_tool_call;
|
||||
pub(crate) use mcp::new_mcp_tools_output;
|
||||
pub(crate) use notice::new_error_event;
|
||||
pub(crate) use notice::new_info_event;
|
||||
pub(crate) use notice::new_warning_event;
|
||||
#[expect(unused_imports)]
|
||||
pub(crate) use patch::PatchHistoryCell;
|
||||
pub(crate) use patch::new_patch_event;
|
||||
#[expect(unused_imports)]
|
||||
pub(crate) use patch_apply_failure::PatchApplyFailureCell;
|
||||
pub(crate) use patch_apply_failure::new_patch_apply_failure;
|
||||
pub(crate) use plain::PlainHistoryCell;
|
||||
#[expect(unused_imports)]
|
||||
pub(crate) use plan::PlanUpdateCell;
|
||||
pub(crate) use plan::new_plan_update;
|
||||
pub(crate) use prefixed_wrapped::PrefixedWrappedHistoryCell;
|
||||
#[expect(unused_imports)]
|
||||
pub(crate) use reasoning_summary::ReasoningSummaryCell;
|
||||
pub(crate) use reasoning_summary::new_reasoning_summary_block;
|
||||
#[expect(unused_imports)]
|
||||
pub(crate) use review_status::ReviewStatusCell;
|
||||
pub(crate) use review_status::new_review_status_line;
|
||||
pub(crate) use session::SessionInfoCell;
|
||||
pub(crate) use session::new_session_info;
|
||||
#[expect(unused_imports)]
|
||||
pub(crate) use update_available::UpdateAvailableHistoryCell;
|
||||
pub(crate) use user::UserHistoryCell;
|
||||
pub(crate) use user::new_user_prompt;
|
||||
#[expect(unused_imports)]
|
||||
pub(crate) use view_image::ViewImageToolCallCell;
|
||||
pub(crate) use view_image::new_view_image_tool_call;
|
||||
#[expect(unused_imports)]
|
||||
pub(crate) use web_search::WebSearchCallCell;
|
||||
pub(crate) use web_search::new_web_search_call;
|
||||
|
||||
/// Represents an event to display in the conversation history. Returns its
|
||||
/// `Vec<Line<'static>>` representation to make it easier to display in a
|
||||
/// scrollable list.
|
||||
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
/// Render this cell into a set of display lines for the on-screen history panel.
|
||||
///
|
||||
/// The width is the available area in the history list. Implementations should wrap or
|
||||
/// truncate as needed so callers can drop the result directly into a `Paragraph`.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>>;
|
||||
|
||||
/// Render this cell to a newline-separated string for display-oriented assertions.
|
||||
///
|
||||
/// This flattens the `display_lines` spans while preserving line breaks so tests can compare
|
||||
/// the rendered shape without manually joining.
|
||||
#[cfg(test)]
|
||||
fn display_string(&self, width: u16) -> String {
|
||||
lines_to_string(&self.display_lines(width))
|
||||
}
|
||||
|
||||
/// Compute the preferred height for the on-screen history panel.
|
||||
///
|
||||
/// The default implementation measures `display_lines` wrapped to the provided width so the
|
||||
/// caller can reserve the exact number of rows needed.
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
Paragraph::new(Text::from(self.display_lines(width)))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(width)
|
||||
.try_into()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Render this cell into a set of lines suitable for transcript export.
|
||||
///
|
||||
/// The default implementation matches `display_lines`, but cells can opt in to returning
|
||||
/// additional context or omit styling-only padding when exporting.
|
||||
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
self.display_lines(width)
|
||||
}
|
||||
|
||||
/// Render this cell to a newline-separated string for transcript assertions.
|
||||
///
|
||||
/// This flattens `transcript_lines` without styling, mirroring `display_string` but using the
|
||||
/// transcript view.
|
||||
#[cfg(test)]
|
||||
fn transcript_string(&self, width: u16) -> String {
|
||||
lines_to_string(&self.transcript_lines(width))
|
||||
}
|
||||
|
||||
/// Compute the preferred transcript height for export views.
|
||||
///
|
||||
/// The default implementation mirrors `desired_height` but operates on `transcript_lines` so
|
||||
/// cells that elide content from the transcript can size correctly.
|
||||
fn desired_transcript_height(&self, width: u16) -> u16 {
|
||||
let lines = self.transcript_lines(width);
|
||||
// Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui
|
||||
// gives 2 lines.
|
||||
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)
|
||||
}
|
||||
|
||||
/// Whether this cell is a continuation of the prior stream output.
|
||||
///
|
||||
/// Streamed agent responses set this so the history renderer can avoid re-drawing an
|
||||
/// intermediate separator.
|
||||
fn is_stream_continuation(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderable for Box<dyn HistoryCell> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = self.display_lines(area.width);
|
||||
let y = if area.height == 0 {
|
||||
0
|
||||
} else {
|
||||
let overflow = lines.len().saturating_sub(usize::from(area.height));
|
||||
u16::try_from(overflow).unwrap_or(u16::MAX)
|
||||
};
|
||||
Paragraph::new(Text::from(lines))
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn flatten_line(line: &Line<'_>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn lines_to_string(lines: &[Line<'_>]) -> String {
|
||||
lines
|
||||
.iter()
|
||||
.map(flatten_line)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Render `lines` inside a border sized to the widest span in the content.
|
||||
pub(crate) fn with_border(lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
|
||||
with_border_internal(lines, None)
|
||||
}
|
||||
|
||||
/// Render `lines` inside a border whose inner width is at least `inner_width`.
|
||||
///
|
||||
/// This is useful when callers have already clamped their content to a
|
||||
/// specific width and want the border math centralized here instead of
|
||||
/// duplicating padding logic in the TUI widgets themselves.
|
||||
pub(crate) fn with_border_with_inner_width(
|
||||
lines: Vec<Line<'static>>,
|
||||
inner_width: usize,
|
||||
) -> Vec<Line<'static>> {
|
||||
with_border_internal(lines, Some(inner_width))
|
||||
}
|
||||
|
||||
fn with_border_internal(
|
||||
lines: Vec<Line<'static>>,
|
||||
forced_inner_width: Option<usize>,
|
||||
) -> Vec<Line<'static>> {
|
||||
let max_line_width = lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.iter()
|
||||
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
||||
.sum::<usize>()
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let content_width = forced_inner_width
|
||||
.unwrap_or(max_line_width)
|
||||
.max(max_line_width);
|
||||
|
||||
let mut out = Vec::with_capacity(lines.len() + 2);
|
||||
let border_inner_width = content_width + 2;
|
||||
out.push(vec![format!("╭{}╮", "─".repeat(border_inner_width)).dim()].into());
|
||||
|
||||
for line in lines.into_iter() {
|
||||
let used_width: usize = line
|
||||
.iter()
|
||||
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
||||
.sum();
|
||||
let span_count = line.spans.len();
|
||||
let mut spans: Vec<Span<'static>> = Vec::with_capacity(span_count + 4);
|
||||
spans.push(Span::from("│ ").dim());
|
||||
spans.extend(line.into_iter());
|
||||
if used_width < content_width {
|
||||
spans.push(Span::from(" ".repeat(content_width - used_width)).dim());
|
||||
}
|
||||
spans.push(Span::from(" │").dim());
|
||||
out.push(Line::from(spans));
|
||||
}
|
||||
|
||||
out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into());
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Return the emoji followed by a hair space (U+200A) to make a compact prefix.
|
||||
/// 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}")
|
||||
}
|
||||
59
codex-rs/tui/src/history_cell/notice.rs
Normal file
59
codex-rs/tui/src/history_cell/notice.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use super::PrefixedWrappedHistoryCell;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Text;
|
||||
|
||||
/// Cyan info bullet with optional dim hint text.
|
||||
pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PrefixedWrappedHistoryCell {
|
||||
let mut line = vec!["• ".dim(), message.into()];
|
||||
if let Some(hint) = hint {
|
||||
line.push(" ".into());
|
||||
line.push(hint.dark_gray());
|
||||
}
|
||||
PrefixedWrappedHistoryCell::new(Text::from(Line::from(line)), Line::from(""), Line::from(""))
|
||||
}
|
||||
|
||||
/// Yellow warning with a hair-space prefix gap.
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
|
||||
PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), " ")
|
||||
}
|
||||
|
||||
/// Red error bullet used for transient error notifications.
|
||||
pub(crate) fn new_error_event(message: String) -> PrefixedWrappedHistoryCell {
|
||||
let line = vec![format!("■ {message}").red()];
|
||||
PrefixedWrappedHistoryCell::new(Line::from(line), Line::from(""), Line::from(""))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn info_event_renders() {
|
||||
let cell = new_info_event(
|
||||
"Indexed docs are up to date.".to_string(),
|
||||
Some("No action needed.".to_string()),
|
||||
);
|
||||
|
||||
assert_snapshot!(cell.display_string(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_event_wraps() {
|
||||
let cell = new_warning_event(
|
||||
"Retry after reconnecting to VPN so the registry is reachable.".into(),
|
||||
);
|
||||
|
||||
assert_snapshot!(cell.display_string(48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_event_renders() {
|
||||
let cell = new_error_event("Patch apply failed; see stderr for details.".into());
|
||||
|
||||
assert_snapshot!(cell.display_string(50));
|
||||
}
|
||||
}
|
||||
173
codex-rs/tui/src/history_cell/patch.rs
Normal file
173
codex-rs/tui/src/history_cell/patch.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use super::HistoryCell;
|
||||
use crate::diff_render::create_diff_summary;
|
||||
use codex_core::protocol::FileChange;
|
||||
use ratatui::text::Line;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// File change summary showing patch metadata in diff card form.
|
||||
///
|
||||
/// Used after `/review` or patch application to list added, modified, and deleted files relative to
|
||||
/// a working directory so users can skim the affected files and line counts before diving into the
|
||||
/// full diff rendering.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// • Added src/lib.rs (+2 -0)
|
||||
/// 1 +fn main() {
|
||||
/// 2 + println!("hi");
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PatchHistoryCell {
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl PatchHistoryCell {
|
||||
pub(crate) fn new(changes: HashMap<PathBuf, FileChange>, cwd: &Path) -> Self {
|
||||
Self {
|
||||
changes,
|
||||
cwd: cwd.to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_patch_event(
|
||||
changes: HashMap<PathBuf, FileChange>,
|
||||
cwd: &Path,
|
||||
) -> PatchHistoryCell {
|
||||
PatchHistoryCell::new(changes, cwd)
|
||||
}
|
||||
|
||||
impl HistoryCell for PatchHistoryCell {
|
||||
/// Render the diff summary for each file with counts and inline hunks.
|
||||
///
|
||||
/// Delegates to `create_diff_summary`, which emits a header summarizing total +/- lines, per-
|
||||
/// file headers (or a single-line header when only one file changed), and an indented block
|
||||
/// showing the hunks for each file with colored +/- gutters and wrapping at `width`.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
create_diff_summary(&self.changes, &self.cwd, width as usize)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::protocol::FileChange;
|
||||
use diffy::create_patch;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn single_added_file_shows_header_and_hunks() {
|
||||
let mut changes = HashMap::new();
|
||||
changes.insert(
|
||||
PathBuf::from("src/lib.rs"),
|
||||
FileChange::Add {
|
||||
content: "fn main() {}\n".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let cell = PatchHistoryCell::new(changes, Path::new("/repo"));
|
||||
let rendered = cell.display_string(80);
|
||||
|
||||
assert!(
|
||||
rendered.starts_with("• Added src/lib.rs (+1 -0)"),
|
||||
"expected single-file header with path and counts:\n{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("+fn main() {}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_files_render_summary_and_move_path() {
|
||||
let mut changes = HashMap::new();
|
||||
let patch = create_patch("old\n", "new\n").to_string();
|
||||
changes.insert(
|
||||
PathBuf::from("/repo/old.txt"),
|
||||
FileChange::Update {
|
||||
unified_diff: patch,
|
||||
move_path: Some(PathBuf::from("/repo/new.txt")),
|
||||
},
|
||||
);
|
||||
changes.insert(
|
||||
PathBuf::from("/repo/added.txt"),
|
||||
FileChange::Add {
|
||||
content: "extra\n".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let cell = PatchHistoryCell::new(changes, Path::new("/repo"));
|
||||
let rendered = cell.display_string(80);
|
||||
|
||||
assert!(
|
||||
rendered.starts_with("• Edited 2 files (+2 -1)"),
|
||||
"expected multi-file summary header:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("/repo/old.txt → /repo/new.txt (+1 -1)"),
|
||||
"rendered output did not include move summary:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("+new"),
|
||||
"rendered output missing applied patch content:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains("added.txt (+1 -0)"),
|
||||
"rendered output missing added file header:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_file_patch_wraps_hunks() {
|
||||
let mut changes = HashMap::new();
|
||||
changes.insert(
|
||||
PathBuf::from("src/lib.rs"),
|
||||
FileChange::Add {
|
||||
content: indoc::indoc! {"
|
||||
fn main() {
|
||||
println!(\"hello world from a very chatty function that will wrap\");
|
||||
}
|
||||
"}
|
||||
.into(),
|
||||
},
|
||||
);
|
||||
|
||||
let cell = PatchHistoryCell::new(changes, Path::new("/repo"));
|
||||
assert_snapshot!(cell.display_string(56));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_files_summary_and_moves() {
|
||||
let mut changes = HashMap::new();
|
||||
let patch = create_patch(
|
||||
indoc::indoc! {"
|
||||
pub fn old() {
|
||||
println!(\"old\");
|
||||
}
|
||||
"},
|
||||
indoc::indoc! {"
|
||||
pub fn renamed() {
|
||||
println!(\"renamed with longer output line to wrap cleanly\");
|
||||
}
|
||||
"},
|
||||
)
|
||||
.to_string();
|
||||
changes.insert(
|
||||
PathBuf::from("/repo/src/old.rs"),
|
||||
FileChange::Update {
|
||||
unified_diff: patch,
|
||||
move_path: Some(PathBuf::from("/repo/src/renamed.rs")),
|
||||
},
|
||||
);
|
||||
changes.insert(
|
||||
PathBuf::from("/repo/docs/notes.md"),
|
||||
FileChange::Add {
|
||||
content: "Added runbook steps for deploy.\n".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let cell = PatchHistoryCell::new(changes, Path::new("/repo"));
|
||||
assert_snapshot!(cell.display_string(64));
|
||||
}
|
||||
}
|
||||
93
codex-rs/tui/src/history_cell/patch_apply_failure.rs
Normal file
93
codex-rs/tui/src/history_cell/patch_apply_failure.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use super::HistoryCell;
|
||||
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 ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Patch application failure banner with truncated stderr.
|
||||
///
|
||||
/// Displays a magenta failure heading and the tail of stderr with tree prefixes so users can see
|
||||
/// the error cause without overwhelming the history pane.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// ✘ Failed to apply patch
|
||||
/// └ line one
|
||||
/// line two
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PatchApplyFailureCell {
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
impl PatchApplyFailureCell {
|
||||
pub(crate) fn new(stderr: String) -> Self {
|
||||
Self { stderr }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for PatchApplyFailureCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
|
||||
|
||||
if !self.stderr.trim().is_empty() {
|
||||
let output = output_lines(
|
||||
Some(&CommandOutput {
|
||||
exit_code: 1,
|
||||
formatted_output: String::new(),
|
||||
aggregated_output: self.stderr.clone(),
|
||||
}),
|
||||
OutputLinesParams {
|
||||
line_limit: TOOL_CALL_MAX_LINES,
|
||||
only_err: true,
|
||||
include_angle_pipe: true,
|
||||
include_prefix: true,
|
||||
},
|
||||
);
|
||||
lines.extend(output.lines);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_patch_apply_failure(stderr: String) -> PatchApplyFailureCell {
|
||||
PatchApplyFailureCell::new(stderr)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn renders_heading_and_stderr_tail() {
|
||||
let stderr = "line one\nline two\nline three\nline four\nline five\nline six";
|
||||
let cell = PatchApplyFailureCell::new(stderr.into());
|
||||
|
||||
let rendered = cell.display_string(80);
|
||||
assert!(rendered.starts_with("✘ Failed to apply patch"));
|
||||
assert!(rendered.contains("└ line one"));
|
||||
assert!(rendered.contains("line six"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_wide() {
|
||||
let stderr = "line one\nline two\nline three\nline four\nline five\nline six";
|
||||
let cell = PatchApplyFailureCell::new(stderr.into());
|
||||
|
||||
assert_snapshot!(cell.display_string(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_narrow() {
|
||||
let stderr = "line one\nline two\nline three\nline four\nline five\nline six";
|
||||
let cell = PatchApplyFailureCell::new(stderr.into());
|
||||
|
||||
assert_snapshot!(cell.display_string(24));
|
||||
}
|
||||
}
|
||||
60
codex-rs/tui/src/history_cell/plain.rs
Normal file
60
codex-rs/tui/src/history_cell/plain.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use super::HistoryCell;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Preformatted content to drop directly into the history.
|
||||
///
|
||||
/// Used for banners, summaries, and helper lines that already include styling/indentation so they
|
||||
/// should be passed through unchanged.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PlainHistoryCell {
|
||||
pub(crate) lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
impl PlainHistoryCell {
|
||||
/// Wrap the given lines in a pass-through cell.
|
||||
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
|
||||
Self { lines }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for PlainHistoryCell {
|
||||
/// Return the provided lines without modification.
|
||||
///
|
||||
/// Width is ignored because callers pre-wrap/pre-style the content before constructing this
|
||||
/// cell.
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
self.lines.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn passes_through_lines_unchanged() {
|
||||
let lines = vec![Line::from("Hello"), Line::from("world")];
|
||||
let cell = PlainHistoryCell::new(lines.clone());
|
||||
|
||||
assert_eq!(cell.display_lines(10), lines);
|
||||
assert_eq!(cell.desired_height(10), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_height_treats_whitespace_as_single_line() {
|
||||
let cell = PlainHistoryCell::new(vec![Line::from(" ")]);
|
||||
|
||||
assert_eq!(cell.desired_transcript_height(24), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_multiline_plain_text() {
|
||||
let cell = PlainHistoryCell::new(vec![
|
||||
Line::from("Summary: Updated the deployment script."),
|
||||
Line::from("Details: Added retries and improved logging output."),
|
||||
]);
|
||||
|
||||
assert_snapshot!(cell.display_string(60));
|
||||
}
|
||||
}
|
||||
138
codex-rs/tui/src/history_cell/plan.rs
Normal file
138
codex-rs/tui/src/history_cell/plan.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
use super::HistoryCell;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use codex_protocol::plan_tool::PlanItemArg;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::plan_tool::UpdatePlanArgs;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Styled;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Checkbox-style rendering of plan updates pushed from the plan tool.
|
||||
///
|
||||
/// Displays an optional italic note followed by each step with a checkbox indicating pending,
|
||||
/// in-progress, or completed status, wrapping lines with consistent indentation.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// • Updated Plan
|
||||
/// └ ✔ done step
|
||||
/// □ pending step
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PlanUpdateCell {
|
||||
explanation: Option<String>,
|
||||
plan: Vec<PlanItemArg>,
|
||||
}
|
||||
|
||||
impl PlanUpdateCell {
|
||||
pub(crate) fn new(update: UpdatePlanArgs) -> Self {
|
||||
let UpdatePlanArgs { explanation, plan } = update;
|
||||
Self { explanation, plan }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
|
||||
PlanUpdateCell::new(update)
|
||||
}
|
||||
|
||||
impl HistoryCell for PlanUpdateCell {
|
||||
/// Render the plan title plus wrapped note and checkbox steps.
|
||||
///
|
||||
/// Emits a bold "Updated Plan" header, then indents an optional dim/italic note. Each plan item
|
||||
/// wraps under a checkbox (`✔` crossed out/dim for completed, cyan `□` for in-progress, dim `□`
|
||||
/// for pending) with hanging indent so wrapped lines align under the step text.
|
||||
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;
|
||||
textwrap::wrap(text, wrap_width)
|
||||
.into_iter()
|
||||
.map(|s| s.to_string().dim().italic().into())
|
||||
.collect()
|
||||
};
|
||||
|
||||
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 wrap_width = (width as usize)
|
||||
.saturating_sub(4)
|
||||
.saturating_sub(box_str.width())
|
||||
.max(1);
|
||||
let parts = textwrap::wrap(text, wrap_width);
|
||||
let step_text = parts
|
||||
.into_iter()
|
||||
.map(|s| s.to_string().set_style(step_style).into())
|
||||
.collect();
|
||||
prefix_lines(step_text, box_str.into(), " ".into())
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn renders_checkbox_steps_and_note() {
|
||||
let update = UpdatePlanArgs {
|
||||
explanation: Some("Wraps the note provided by the plan tool".into()),
|
||||
plan: vec![
|
||||
PlanItemArg {
|
||||
step: "Investigate errors".into(),
|
||||
status: StepStatus::Completed,
|
||||
},
|
||||
PlanItemArg {
|
||||
step: "Add retries to client".into(),
|
||||
status: StepStatus::InProgress,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let cell = PlanUpdateCell::new(update);
|
||||
let rendered = cell.display_string(32);
|
||||
|
||||
assert_snapshot!(rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_placeholder_when_no_steps() {
|
||||
let cell = PlanUpdateCell::new(UpdatePlanArgs {
|
||||
explanation: None,
|
||||
plan: Vec::new(),
|
||||
});
|
||||
|
||||
let rendered = cell.display_string(40);
|
||||
assert_snapshot!(rendered);
|
||||
}
|
||||
}
|
||||
110
codex-rs/tui/src/history_cell/prefixed_wrapped.rs
Normal file
110
codex-rs/tui/src/history_cell/prefixed_wrapped.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use super::HistoryCell;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Text;
|
||||
|
||||
/// A wrapped text block with distinct prefixes for the first and subsequent lines.
|
||||
///
|
||||
/// Callers supply text and prefixes; the cell handles wrapping and prefixing for approval banners,
|
||||
/// warnings, and other note-style entries.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// ✔ You approved codex
|
||||
/// to run echo ...
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PrefixedWrappedHistoryCell {
|
||||
pub(crate) text: Text<'static>,
|
||||
pub(crate) initial_prefix: Line<'static>,
|
||||
pub(crate) subsequent_prefix: Line<'static>,
|
||||
}
|
||||
|
||||
impl PrefixedWrappedHistoryCell {
|
||||
/// Construct a prefix-aware wrapped cell.
|
||||
///
|
||||
/// Callers provide the content plus distinct prefixes for the first and subsequent lines;
|
||||
/// wrapping is handled here so long notes and banners keep a consistent hanging indent.
|
||||
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 {
|
||||
/// Wrap text and apply distinct prefixes on the first vs subsequent lines.
|
||||
///
|
||||
/// Uses `word_wrap_lines` to honor the available width, then emits the caller-provided
|
||||
/// `initial_prefix` on the first line and `subsequent_prefix` on following lines. Useful for
|
||||
/// note-style blocks like approvals and warnings that need a consistent hanging indent.
|
||||
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());
|
||||
let wrapped = word_wrap_lines(&self.text, opts);
|
||||
let mut out = Vec::new();
|
||||
push_owned_lines(&wrapped, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Measure by counting wrapped lines.
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
self.display_lines(width).len() as u16
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
#[test]
|
||||
fn indents_wrapped_lines() {
|
||||
let summary = Line::from(vec![
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
" codex to run ".into(),
|
||||
"echo something really long to ensure wrapping happens".dim(),
|
||||
" this time".bold(),
|
||||
]);
|
||||
let cell = PrefixedWrappedHistoryCell::new(summary, "✔ ".green(), " ");
|
||||
let rendered = cell.display_string(24);
|
||||
assert_eq!(
|
||||
rendered,
|
||||
indoc::indoc! {"
|
||||
✔ You approved codex
|
||||
to run echo something
|
||||
really long to ensure
|
||||
wrapping happens this
|
||||
time"}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warning_message_wraps_with_hanging_indent() {
|
||||
let cell = PrefixedWrappedHistoryCell::new(
|
||||
Text::from(
|
||||
"Warning: reconnect to the VPN before retrying so the service endpoint is reachable.",
|
||||
),
|
||||
#[expect(clippy::disallowed_methods)]
|
||||
"⚠ ".yellow(),
|
||||
" ",
|
||||
);
|
||||
|
||||
assert_snapshot!(cell.display_string(38));
|
||||
}
|
||||
}
|
||||
289
codex-rs/tui/src/history_cell/reasoning_summary.rs
Normal file
289
codex-rs/tui/src/history_cell/reasoning_summary.rs
Normal file
@@ -0,0 +1,289 @@
|
||||
use super::HistoryCell;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Model-provided reasoning summary shown alongside or inside the transcript.
|
||||
///
|
||||
/// Captures the assistant’s self-reported reasoning buffer so users can read concise bullet points
|
||||
/// without inspecting raw deltas. When `transcript_only` is true, the summary is omitted from the
|
||||
/// on-screen history and only emitted in exports. Renders as a dim, italic bullet with hanging
|
||||
/// indent when visible.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// • we wrap the summary with a hanging indent
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ReasoningSummaryCell {
|
||||
_header: String,
|
||||
content: String,
|
||||
transcript_only: bool,
|
||||
}
|
||||
|
||||
impl ReasoningSummaryCell {
|
||||
/// Create a reasoning summary entry anchored to the assistant’s optional header.
|
||||
///
|
||||
/// `content` is the markdown-formatted summary text; when `transcript_only` is true the summary
|
||||
/// is omitted from the on-screen history while remaining in transcript exports.
|
||||
pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self {
|
||||
Self {
|
||||
_header: header,
|
||||
content,
|
||||
transcript_only,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render summary content as dim, italic bullet lines.
|
||||
///
|
||||
/// Parses markdown to spans, dims/italicizes the text, then wraps with a bullet and hanging
|
||||
/// indent so multi-line summaries stay aligned within the available width.
|
||||
fn lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
append_markdown(
|
||||
&self.content,
|
||||
Some((width as usize).saturating_sub(2)),
|
||||
&mut lines,
|
||||
);
|
||||
let summary_style = Style::default().dim().italic();
|
||||
let summary_lines = lines
|
||||
.into_iter()
|
||||
.map(|mut line| {
|
||||
line.spans = line
|
||||
.spans
|
||||
.into_iter()
|
||||
.map(|span| span.patch_style(summary_style))
|
||||
.collect();
|
||||
line
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Render as a bullet with hanging indent so multi-line reasoning stays aligned.
|
||||
word_wrap_lines(
|
||||
&summary_lines,
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent("• ".dim().into())
|
||||
.subsequent_indent(" ".into()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for ReasoningSummaryCell {
|
||||
/// Return dim, italic bullet lines for the reasoning summary or hide when transcript-only.
|
||||
///
|
||||
/// When `transcript_only` is false, wraps markdown-rendered content to the given width with a
|
||||
/// dim bullet and hanging indent. When true, returns an empty vector so on-screen history omits
|
||||
/// the summary, but `transcript_lines` still returns the full wrapped content.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
if self.transcript_only {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.lines(width)
|
||||
}
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
if self.transcript_only {
|
||||
0
|
||||
} else {
|
||||
self.lines(width).len() as u16
|
||||
}
|
||||
}
|
||||
|
||||
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
self.lines(width)
|
||||
}
|
||||
|
||||
fn desired_transcript_height(&self, width: u16) -> u16 {
|
||||
self.lines(width).len() as u16
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a reasoning summary cell, stripping any experimental header markup when configured.
|
||||
pub(crate) fn new_reasoning_summary_block(
|
||||
full_reasoning_buffer: String,
|
||||
config: &codex_core::config::Config,
|
||||
) -> Box<dyn HistoryCell> {
|
||||
use codex_core::config::types::ReasoningSummaryFormat;
|
||||
|
||||
if config.model_family.reasoning_summary_format == ReasoningSummaryFormat::Experimental {
|
||||
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 after_close_idx < full_reasoning_buffer.len() {
|
||||
let header_buffer = full_reasoning_buffer[..after_close_idx].to_string();
|
||||
let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string();
|
||||
return Box::new(ReasoningSummaryCell::new(
|
||||
header_buffer,
|
||||
summary_buffer,
|
||||
false,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box::new(ReasoningSummaryCell::new(
|
||||
"".to_string(),
|
||||
full_reasoning_buffer,
|
||||
true,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::config::types::ReasoningSummaryFormat;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
fn test_config() -> Config {
|
||||
Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
std::env::temp_dir(),
|
||||
)
|
||||
.expect("config")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_when_transcript_only() {
|
||||
let cell = ReasoningSummaryCell::new("".into(), "hidden".into(), true);
|
||||
assert!(cell.display_lines(80).is_empty());
|
||||
assert_eq!(cell.desired_height(80), 0);
|
||||
assert!(!cell.transcript_lines(80).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_and_wraps_summary() {
|
||||
let cell = ReasoningSummaryCell::new(
|
||||
"".into(),
|
||||
"A fairly long reasoning line that will wrap in the bullet summary when narrow".into(),
|
||||
false,
|
||||
);
|
||||
|
||||
let rendered = cell.display_string(30);
|
||||
assert!(
|
||||
rendered.starts_with('•'),
|
||||
"expected bullet prefix in reasoning summary"
|
||||
);
|
||||
assert!(
|
||||
rendered.contains('\n'),
|
||||
"expected wrapping when narrow summary width"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_summary_wraps() {
|
||||
let cell = ReasoningSummaryCell::new(
|
||||
"".into(),
|
||||
"We should refactor the history cells into modules and add snapshot coverage to lock rendering behavior."
|
||||
.into(),
|
||||
false,
|
||||
);
|
||||
|
||||
assert_snapshot!(cell.display_string(46));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn experimental_blocking_logic_matches_helper() {
|
||||
let mut config = test_config();
|
||||
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
||||
|
||||
let cell = ReasoningSummaryCell::new("header".into(), "body".into(), false);
|
||||
let rendered = cell.display_string(80);
|
||||
assert!(rendered.contains("body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_header_and_summary_when_present() {
|
||||
let mut config = test_config();
|
||||
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
||||
|
||||
let cell = new_reasoning_summary_block(
|
||||
"**High level plan**\n\nWe should fix the bug next.".to_string(),
|
||||
&config,
|
||||
);
|
||||
|
||||
let rendered_display = cell.display_string(80);
|
||||
let rendered_transcript = cell.transcript_string(80);
|
||||
assert_eq!(rendered_display, "• We should fix the bug next.");
|
||||
assert_eq!(rendered_transcript, "• We should fix the bug next.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_when_header_is_missing() {
|
||||
let mut config = test_config();
|
||||
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
||||
|
||||
let cell = new_reasoning_summary_block(
|
||||
"**High level reasoning without closing".to_string(),
|
||||
&config,
|
||||
);
|
||||
|
||||
let rendered = cell.transcript_string(80);
|
||||
assert_eq!(rendered, "• **High level reasoning without closing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_when_summary_is_missing() {
|
||||
let mut config = test_config();
|
||||
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
||||
|
||||
let cell = new_reasoning_summary_block(
|
||||
"**High level reasoning without closing**".to_string(),
|
||||
&config,
|
||||
);
|
||||
assert_eq!(
|
||||
cell.transcript_string(80),
|
||||
"• High level reasoning without closing"
|
||||
);
|
||||
|
||||
let cell = new_reasoning_summary_block(
|
||||
"**High level reasoning without closing**\n\n ".to_string(),
|
||||
&config,
|
||||
);
|
||||
assert_eq!(
|
||||
cell.transcript_string(80),
|
||||
"• High level reasoning without closing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_reasoning_cell_when_feature_disabled() {
|
||||
let mut config = test_config();
|
||||
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
||||
|
||||
let cell =
|
||||
new_reasoning_summary_block("Detailed reasoning goes here.".to_string(), &config);
|
||||
|
||||
assert_eq!(
|
||||
cell.transcript_string(80),
|
||||
"• Detailed reasoning goes here."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_path_header_and_summary() {
|
||||
let mut config = test_config();
|
||||
config.model_family.reasoning_summary_format = ReasoningSummaryFormat::Experimental;
|
||||
|
||||
let cell = new_reasoning_summary_block(
|
||||
"**High level reasoning**\n\nDetailed reasoning goes here.".to_string(),
|
||||
&config,
|
||||
);
|
||||
|
||||
assert_eq!(cell.display_string(80), "• Detailed reasoning goes here.");
|
||||
assert_eq!(
|
||||
cell.transcript_string(80),
|
||||
"• Detailed reasoning goes here."
|
||||
);
|
||||
}
|
||||
}
|
||||
56
codex-rs/tui/src/history_cell/review_status.rs
Normal file
56
codex-rs/tui/src/history_cell/review_status.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use super::HistoryCell;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Single review status line rendered in cyan.
|
||||
///
|
||||
/// Shown in the history when a review op is in progress or completed. The message is pre-styled and
|
||||
/// unwrapped, so it stays compact within the available width.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// • Review approved
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ReviewStatusCell {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ReviewStatusCell {
|
||||
pub(crate) fn new(message: String) -> Self {
|
||||
Self { message }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for ReviewStatusCell {
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
vec![Line::from(self.message.clone().cyan())]
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_review_status_line(message: String) -> ReviewStatusCell {
|
||||
ReviewStatusCell::new(message)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn renders_cyan_message() {
|
||||
let cell = ReviewStatusCell::new("Review status".into());
|
||||
|
||||
assert_eq!(cell.display_string(80), "Review status");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_status_message_renders() {
|
||||
let cell = ReviewStatusCell::new(
|
||||
"Review in progress: applying feedback and rerunning checks".into(),
|
||||
);
|
||||
|
||||
assert_snapshot!(cell.display_string(64));
|
||||
}
|
||||
}
|
||||
421
codex-rs/tui/src/history_cell/session.rs
Normal file
421
codex-rs/tui/src/history_cell/session.rs
Normal file
@@ -0,0 +1,421 @@
|
||||
use super::CompositeHistoryCell;
|
||||
use super::HistoryCell;
|
||||
use super::with_border;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Boxed header that shows the current model, reasoning effort, and working directory.
|
||||
///
|
||||
/// Rendered at the top of a session in the history panel to orient the user before any exchanges
|
||||
/// occur. Uses a bordered card with a dim prompt prefix, bold title, model line (including
|
||||
/// reasoning effort when configured), and the current working directory.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// ╭──────────────────────────────╮
|
||||
/// │ >_ OpenAI Codex (vX.Y.Z) │
|
||||
/// │ │
|
||||
/// │ model: gpt-4o high /model …│
|
||||
/// │ directory: ~/code/project │
|
||||
/// ╰──────────────────────────────╯
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SessionHeaderHistoryCell {
|
||||
version: &'static str,
|
||||
model: String,
|
||||
reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
directory: PathBuf,
|
||||
}
|
||||
|
||||
impl SessionHeaderHistoryCell {
|
||||
pub(crate) fn new(
|
||||
model: String,
|
||||
reasoning_effort: Option<ReasoningEffortConfig>,
|
||||
directory: PathBuf,
|
||||
version: &'static str,
|
||||
) -> Self {
|
||||
Self {
|
||||
version,
|
||||
model,
|
||||
reasoning_effort,
|
||||
directory,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a directory path with `~` shorthand and optional truncation to fit the card width.
|
||||
pub(crate) fn format_directory(&self, max_width: Option<usize>) -> String {
|
||||
Self::format_directory_inner(&self.directory, max_width)
|
||||
}
|
||||
|
||||
fn format_directory_inner(directory: &Path, max_width: Option<usize>) -> String {
|
||||
let formatted = if let Some(rel) = relativize_to_home(directory) {
|
||||
if rel.as_os_str().is_empty() {
|
||||
"~".to_string()
|
||||
} else {
|
||||
format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display())
|
||||
}
|
||||
} else {
|
||||
directory.display().to_string()
|
||||
};
|
||||
|
||||
if let Some(max_width) = max_width {
|
||||
if max_width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if UnicodeWidthStr::width(formatted.as_str()) > max_width {
|
||||
return crate::text_formatting::center_truncate_path(&formatted, max_width);
|
||||
}
|
||||
}
|
||||
|
||||
formatted
|
||||
}
|
||||
|
||||
fn reasoning_label(&self) -> Option<&'static str> {
|
||||
self.reasoning_effort.map(|effort| match effort {
|
||||
ReasoningEffortConfig::Minimal => "minimal",
|
||||
ReasoningEffortConfig::Low => "low",
|
||||
ReasoningEffortConfig::Medium => "medium",
|
||||
ReasoningEffortConfig::High => "high",
|
||||
ReasoningEffortConfig::XHigh => "xhigh",
|
||||
ReasoningEffortConfig::None => "none",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for SessionHeaderHistoryCell {
|
||||
/// Render the banner boxed with model/version and directory lines sized to the available width.
|
||||
///
|
||||
/// Computes the inner card width (clamped to `SESSION_HEADER_MAX_INNER_WIDTH`), then lays out
|
||||
/// a prompt-style title, a blank spacer line, a model line with reasoning label and `/model`
|
||||
/// hint, and a directory line with truncation as needed. The entire block is wrapped in a light
|
||||
/// border.
|
||||
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);
|
||||
|
||||
let title_spans: Vec<Span<'static>> = vec![
|
||||
Span::from(">_ ").dim(),
|
||||
Span::from("OpenAI Codex").bold(),
|
||||
Span::from(" ").dim(),
|
||||
Span::from(format!("(v{})", self.version)).dim(),
|
||||
];
|
||||
|
||||
const CHANGE_MODEL_HINT_COMMAND: &str = "/model";
|
||||
const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change";
|
||||
const DIR_LABEL: &str = "directory:";
|
||||
let label_width = DIR_LABEL.len();
|
||||
let model_label = format!(
|
||||
"{model_label:<label_width$}",
|
||||
model_label = "model:",
|
||||
label_width = label_width
|
||||
);
|
||||
let reasoning_label = self.reasoning_label();
|
||||
let mut model_spans: Vec<Span<'static>> = vec![
|
||||
Span::from(format!("{model_label} ")).dim(),
|
||||
Span::from(self.model.clone()),
|
||||
];
|
||||
if let Some(reasoning) = reasoning_label {
|
||||
model_spans.push(Span::from(" "));
|
||||
model_spans.push(Span::from(reasoning));
|
||||
}
|
||||
model_spans.push(" ".dim());
|
||||
model_spans.push(CHANGE_MODEL_HINT_COMMAND.cyan());
|
||||
model_spans.push(CHANGE_MODEL_HINT_EXPLANATION.dim());
|
||||
|
||||
let dir_label = format!("{DIR_LABEL:<label_width$}");
|
||||
let dir_prefix = format!("{dir_label} ");
|
||||
let dir_prefix_width = UnicodeWidthStr::width(dir_prefix.as_str());
|
||||
let dir_max_width = inner_width.saturating_sub(dir_prefix_width);
|
||||
let dir = self.format_directory(Some(dir_max_width));
|
||||
let dir_spans = vec![Span::from(dir_prefix).dim(), Span::from(dir)];
|
||||
|
||||
let lines = vec![
|
||||
make_row(title_spans),
|
||||
make_row(Vec::new()),
|
||||
make_row(model_spans),
|
||||
make_row(dir_spans),
|
||||
];
|
||||
|
||||
with_border(lines)
|
||||
}
|
||||
}
|
||||
|
||||
/// Composite cell used for the session header and onboarding hints.
|
||||
///
|
||||
/// Displays the initial "OpenAI Codex" banner with model/directory info and a short help list when
|
||||
/// a session starts, or an empty placeholder when reconfiguring without changes. Combines a header
|
||||
/// card with plain help lines so the history entry is a single block.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// ╭─ header card ─╮
|
||||
/// │ model / dir │
|
||||
/// ╰───────────────╯
|
||||
///
|
||||
/// To get started...
|
||||
/// /init - ...
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct SessionInfoCell(CompositeHistoryCell);
|
||||
|
||||
impl SessionInfoCell {
|
||||
pub(crate) fn new(
|
||||
config: &Config,
|
||||
event: SessionConfiguredEvent,
|
||||
is_first_event: bool,
|
||||
) -> Self {
|
||||
let SessionConfiguredEvent {
|
||||
model,
|
||||
reasoning_effort,
|
||||
..
|
||||
} = event;
|
||||
if is_first_event {
|
||||
let header = SessionHeaderHistoryCell::new(
|
||||
model,
|
||||
reasoning_effort,
|
||||
config.cwd.clone(),
|
||||
crate::version::CODEX_CLI_VERSION,
|
||||
);
|
||||
|
||||
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(),
|
||||
"/approvals".into(),
|
||||
" - choose what Codex can do without approval".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(),
|
||||
]),
|
||||
];
|
||||
|
||||
Self(CompositeHistoryCell {
|
||||
parts: vec![
|
||||
Box::new(header),
|
||||
Box::new(crate::history_cell::PlainHistoryCell { lines: help_lines }),
|
||||
],
|
||||
})
|
||||
} else if config.model == model {
|
||||
Self(CompositeHistoryCell { parts: vec![] })
|
||||
} else {
|
||||
let lines = vec![
|
||||
"model changed:".magenta().bold().into(),
|
||||
format!("requested: {}", config.model).into(),
|
||||
format!("used: {model}").into(),
|
||||
];
|
||||
|
||||
Self(CompositeHistoryCell {
|
||||
parts: vec![Box::new(crate::history_cell::PlainHistoryCell { lines })],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_session_info(
|
||||
config: &Config,
|
||||
event: SessionConfiguredEvent,
|
||||
is_first_event: bool,
|
||||
) -> SessionInfoCell {
|
||||
SessionInfoCell::new(config, event, is_first_event)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // eyeballed
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_protocol::ConversationId;
|
||||
use dirs::home_dir;
|
||||
use insta::assert_snapshot;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn test_config() -> Config {
|
||||
Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
std::env::temp_dir(),
|
||||
)
|
||||
.expect("config")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_reasoning_level_when_present() {
|
||||
let cell = SessionHeaderHistoryCell::new(
|
||||
"gpt-4o".to_string(),
|
||||
Some(ReasoningEffortConfig::High),
|
||||
std::env::temp_dir(),
|
||||
"test",
|
||||
);
|
||||
|
||||
let lines: Vec<String> = cell
|
||||
.display_string(80)
|
||||
.split('\n')
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let model_line = lines
|
||||
.iter()
|
||||
.find(|line| line.contains("model:"))
|
||||
.expect("model line");
|
||||
|
||||
assert!(model_line.contains("gpt-4o high"));
|
||||
assert!(model_line.contains("/model to change"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_center_truncates_for_nested_paths() {
|
||||
let mut dir = home_dir().expect("home directory");
|
||||
for part in ["hello", "the", "fox", "is", "very", "fast"] {
|
||||
dir.push(part);
|
||||
}
|
||||
|
||||
let formatted = SessionHeaderHistoryCell::format_directory_inner(&dir, Some(24));
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let expected = format!("~{sep}hello{sep}the{sep}…{sep}very{sep}fast");
|
||||
assert_eq!(formatted, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_front_truncates_long_segment() {
|
||||
let mut dir = home_dir().expect("home directory");
|
||||
dir.push("supercalifragilisticexpialidocious");
|
||||
|
||||
let formatted = SessionHeaderHistoryCell::format_directory_inner(&dir, Some(18));
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let expected = format!("~{sep}…cexpialidocious");
|
||||
assert_eq!(formatted, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_info_renders_header_and_help() {
|
||||
let config = test_config();
|
||||
let cell = SessionInfoCell::new(
|
||||
&config,
|
||||
SessionConfiguredEvent {
|
||||
session_id: ConversationId::new(),
|
||||
model: "gpt-4o".into(),
|
||||
model_provider_id: "test".into(),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
cwd: config.cwd.clone(),
|
||||
reasoning_effort: None,
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: PathBuf::from("/tmp/rollout"),
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
let rendered = cell.display_string(60);
|
||||
assert!(rendered.contains("OpenAI Codex"));
|
||||
assert!(rendered.contains("/model"));
|
||||
assert!(rendered.contains("/status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_header_full_width() {
|
||||
let cell = SessionHeaderHistoryCell::new(
|
||||
"gpt-4o-mini".to_string(),
|
||||
Some(ReasoningEffortConfig::High),
|
||||
PathBuf::from("/Users/me/projects/codex"),
|
||||
"1.2.3",
|
||||
);
|
||||
|
||||
assert_snapshot!(cell.display_string(72));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_header_truncates_directory() {
|
||||
let cell = SessionHeaderHistoryCell::new(
|
||||
"gpt-4o-mini".to_string(),
|
||||
None,
|
||||
PathBuf::from("/Users/me/projects/codex"),
|
||||
"1.2.3",
|
||||
);
|
||||
|
||||
assert_snapshot!(cell.display_string(36));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_info_includes_help() {
|
||||
let config = test_config();
|
||||
let cell = SessionInfoCell::new(
|
||||
&config,
|
||||
SessionConfiguredEvent {
|
||||
session_id: ConversationId::new(),
|
||||
model: "gpt-4o".into(),
|
||||
model_provider_id: "test".into(),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
cwd: config.cwd.clone(),
|
||||
reasoning_effort: Some(ReasoningEffortConfig::Medium),
|
||||
history_log_id: 0,
|
||||
history_entry_count: 0,
|
||||
initial_messages: None,
|
||||
rollout_path: PathBuf::from("/tmp/rollout"),
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
assert_snapshot!(cell.display_string(70));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell/agent.rs
|
||||
expression: cell.display_string(32)
|
||||
---
|
||||
Then continue streaming
|
||||
without a new bullet.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/agent.rs
|
||||
expression: cell.display_string(34)
|
||||
---
|
||||
• Here is how to fix the failing
|
||||
tests by adjusting the mock
|
||||
responses.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/approval_decision.rs
|
||||
expression: "render(codex_core::protocol::ReviewDecision::Abort)"
|
||||
---
|
||||
✗ You canceled the request to run echo
|
||||
checking migration script safety before
|
||||
applying
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/approval_decision.rs
|
||||
expression: "render(codex_core::protocol::ReviewDecision::Approved)"
|
||||
---
|
||||
✔ You approved codex to run echo checking
|
||||
migration script safety before applying
|
||||
this time
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/approval_decision.rs
|
||||
expression: "render(codex_core::protocol::ReviewDecision::ApprovedForSession)"
|
||||
---
|
||||
✔ You approved codex to run echo checking
|
||||
migration script safety before applying
|
||||
every time this session
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/approval_decision.rs
|
||||
expression: "render(codex_core::protocol::ReviewDecision::Denied)"
|
||||
---
|
||||
✗ You did not approve codex to run echo
|
||||
checking migration script safety before
|
||||
applying
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/composite.rs
|
||||
expression: composite.display_string(48)
|
||||
---
|
||||
Session header: OpenAI Codex (v1.2)
|
||||
|
||||
Help: Press ? to see keyboard shortcuts for navigating history.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/history_cell/deprecation.rs
|
||||
assertion_line: 54
|
||||
expression: rendered
|
||||
---
|
||||
⚠ Feature flag `foo`
|
||||
Use flag `bar` instead
|
||||
of relying on implicit
|
||||
defaults.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell/deprecation.rs
|
||||
assertion_line: 102
|
||||
expression: rendered
|
||||
---
|
||||
⚠ Old endpoint deprecated
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
source: tui/src/history_cell/exec.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Explored
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
source: tui/src/history_cell/exec.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Explored
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
source: tui/src/history_cell/exec.rs
|
||||
assertion_line: 66
|
||||
expression: rendered
|
||||
---
|
||||
• Explored
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
source: tui/src/history_cell/exec.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Ran first_token_is_long_en
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
source: tui/src/history_cell/exec.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Ran echo one
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
source: tui/src/history_cell/exec.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Ran set -o pipefail
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
source: tui/src/history_cell/exec.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Ran echo
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
source: tui/src/history_cell/exec.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Ran echo ok
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
source: tui/src/history_cell/exec.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Ran a_very_long_token_
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
source: tui/src/history_cell/exec.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Ran seq 1 10 1>&2 && false
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell/final_separator.rs
|
||||
expression: sep.display_string(40)
|
||||
---
|
||||
─ Worked for 6m 12s ────────────────────
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell/final_separator.rs
|
||||
expression: sep.display_string(34)
|
||||
---
|
||||
──────────────────────────────────
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell/final_separator.rs
|
||||
expression: sep.display_string(40)
|
||||
---
|
||||
─ Worked for 6m 12s ────────────────────
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell/final_separator.rs
|
||||
expression: sep.display_string(34)
|
||||
---
|
||||
──────────────────────────────────
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 1740
|
||||
source: tui/src/history_cell/mcp.rs
|
||||
assertion_line: 636
|
||||
expression: rendered
|
||||
---
|
||||
• Calling search.find_docs({"query":"ratatui styling","limit":3})
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 1817
|
||||
source: tui/src/history_cell/mcp.rs
|
||||
assertion_line: 689
|
||||
expression: rendered
|
||||
---
|
||||
• Called search.find_docs({"query":"ratatui styling","limit":3})
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 1934
|
||||
source: tui/src/history_cell/mcp.rs
|
||||
assertion_line: 805
|
||||
expression: rendered
|
||||
---
|
||||
• Called metrics.summary({"metric":"trace.latency","window":"15m"})
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 1850
|
||||
source: tui/src/history_cell/mcp.rs
|
||||
assertion_line: 734
|
||||
expression: rendered
|
||||
---
|
||||
• Called
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 1795
|
||||
source: tui/src/history_cell/mcp.rs
|
||||
assertion_line: 668
|
||||
expression: rendered
|
||||
---
|
||||
• Called search.find_docs({"query":"ratatui styling","limit":3})
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 1891
|
||||
source: tui/src/history_cell/mcp.rs
|
||||
assertion_line: 766
|
||||
expression: rendered
|
||||
---
|
||||
• Called
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
assertion_line: 1540
|
||||
source: tui/src/history_cell/mcp.rs
|
||||
assertion_line: 619
|
||||
expression: rendered
|
||||
---
|
||||
/mcp
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell/notice.rs
|
||||
assertion_line: 55
|
||||
expression: cell.display_string(50)
|
||||
---
|
||||
■ Patch apply failed; see stderr for details.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/notice.rs
|
||||
assertion_line: 40
|
||||
expression: cell.display_string(42)
|
||||
---
|
||||
• Indexed docs are up to date. No action
|
||||
needed.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/notice.rs
|
||||
assertion_line: 48
|
||||
expression: cell.display_string(48)
|
||||
---
|
||||
⚠ Retry after reconnecting to VPN so the
|
||||
registry is reachable.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/history_cell/patch.rs
|
||||
expression: cell.display_string(64)
|
||||
---
|
||||
• Edited 2 files (+3 -2)
|
||||
└ /repo/docs/notes.md (+1 -0)
|
||||
1 +Added runbook steps for deploy.
|
||||
|
||||
└ /repo/src/old.rs → /repo/src/renamed.rs (+2 -2)
|
||||
1 -pub fn old() {
|
||||
2 - println!("old");
|
||||
1 +pub fn renamed() {
|
||||
2 + println!("renamed with longer output line to wrap cle
|
||||
anly");
|
||||
3 }
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/history_cell/patch.rs
|
||||
expression: cell.display_string(56)
|
||||
---
|
||||
• Added src/lib.rs (+3 -0)
|
||||
1 +fn main() {
|
||||
2 + println!("hello world from a very chatty func
|
||||
tion that will wrap");
|
||||
3 +}
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/history_cell/patch.rs
|
||||
expression: cell.display_string(64)
|
||||
---
|
||||
• Edited 2 files (+3 -2)
|
||||
└ /repo/docs/notes.md (+1 -0)
|
||||
1 +Added runbook steps for deploy.
|
||||
|
||||
└ /repo/src/old.rs → /repo/src/renamed.rs (+2 -2)
|
||||
1 -pub fn old() {
|
||||
2 - println!("old");
|
||||
1 +pub fn renamed() {
|
||||
2 + println!("renamed with longer output line to wrap cle
|
||||
anly");
|
||||
3 }
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/history_cell/patch.rs
|
||||
expression: cell.display_string(56)
|
||||
---
|
||||
• Added src/lib.rs (+3 -0)
|
||||
1 +fn main() {
|
||||
2 + println!("hello world from a very chatty func
|
||||
tion that will wrap");
|
||||
3 +}
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/history_cell/patch_apply_failure.rs
|
||||
assertion_line: 91
|
||||
expression: cell.display_string(24)
|
||||
---
|
||||
✘ Failed to apply patch
|
||||
└ line one
|
||||
line two
|
||||
line three
|
||||
line four
|
||||
line five
|
||||
line six
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
source: tui/src/history_cell/patch_apply_failure.rs
|
||||
assertion_line: 83
|
||||
expression: cell.display_string(80)
|
||||
---
|
||||
✘ Failed to apply patch
|
||||
└ line one
|
||||
line two
|
||||
line three
|
||||
line four
|
||||
line five
|
||||
line six
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell/plain.rs
|
||||
expression: cell.display_string(60)
|
||||
---
|
||||
Summary: Updated the deployment script.
|
||||
Details: Added retries and improved logging output.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell/plain.rs
|
||||
expression: cell.display_string(60)
|
||||
---
|
||||
Summary: Updated the deployment script.
|
||||
Details: Added retries and improved logging output.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/history_cell/plan.rs
|
||||
assertion_line: 120
|
||||
expression: rendered
|
||||
---
|
||||
• Updated Plan
|
||||
└ Wraps the note provided by
|
||||
the plan tool
|
||||
✔ Investigate errors
|
||||
□ Add retries to client
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/plan.rs
|
||||
assertion_line: 131
|
||||
expression: rendered
|
||||
---
|
||||
• Updated Plan
|
||||
└ (no steps provided)
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/prefixed_wrapped.rs
|
||||
expression: cell.display_string(38)
|
||||
---
|
||||
⚠ Warning: reconnect to the VPN before
|
||||
retrying so the service endpoint is
|
||||
reachable.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/prefixed_wrapped.rs
|
||||
expression: cell.display_string(38)
|
||||
---
|
||||
⚠ Warning: reconnect to the VPN before
|
||||
retrying so the service endpoint is
|
||||
reachable.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/reasoning_summary.rs
|
||||
expression: cell.display_string(46)
|
||||
---
|
||||
• We should refactor the history cells into
|
||||
modules and add snapshot coverage to lock
|
||||
rendering behavior.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/reasoning_summary.rs
|
||||
expression: cell.display_string(46)
|
||||
---
|
||||
• We should refactor the history cells into
|
||||
modules and add snapshot coverage to lock
|
||||
rendering behavior.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell/review_status.rs
|
||||
expression: cell.display_string(64)
|
||||
---
|
||||
Review in progress: applying feedback and rerunning checks
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/history_cell/review_status.rs
|
||||
expression: cell.display_string(64)
|
||||
---
|
||||
Review in progress: applying feedback and rerunning checks
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/history_cell/session.rs
|
||||
expression: cell.display_string(72)
|
||||
---
|
||||
╭────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v1.2.3) │
|
||||
│ │
|
||||
│ model: gpt-4o-mini high /model to change │
|
||||
│ directory: /Users/me/projects/codex │
|
||||
╰────────────────────────────────────────────────╯
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/history_cell/session.rs
|
||||
expression: cell.display_string(36)
|
||||
---
|
||||
╭───────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v1.2.3) │
|
||||
│ │
|
||||
│ model: gpt-4o-mini /model to change │
|
||||
│ directory: /Users/me/…/codex │
|
||||
╰───────────────────────────────────────────╯
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/history_cell/session.rs
|
||||
expression: cell.display_string(70)
|
||||
---
|
||||
╭─────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ model: gpt-4o medium /model to change │
|
||||
│ directory: ~/code/codex3/codex-rs/tui │
|
||||
╰─────────────────────────────────────────────╯
|
||||
|
||||
To get started, describe a task or try one of these commands:
|
||||
|
||||
/init - create an AGENTS.md file with instructions for Codex
|
||||
/status - show current session configuration
|
||||
/approvals - choose what Codex can do without approval
|
||||
/model - choose what model and reasoning effort to use
|
||||
/review - review any changes and find issues
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/history_cell/session.rs
|
||||
expression: cell.display_string(36)
|
||||
---
|
||||
╭───────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v1.2.3) │
|
||||
│ │
|
||||
│ model: gpt-4o-mini /model to change │
|
||||
│ directory: /Users/me/…/codex │
|
||||
╰───────────────────────────────────────────╯
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/history_cell/session.rs
|
||||
expression: cell.display_string(72)
|
||||
---
|
||||
╭────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v1.2.3) │
|
||||
│ │
|
||||
│ model: gpt-4o-mini high /model to change │
|
||||
│ directory: /Users/me/projects/codex │
|
||||
╰────────────────────────────────────────────────╯
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/history_cell/session.rs
|
||||
expression: cell.display_string(70)
|
||||
---
|
||||
╭─────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ model: gpt-4o medium /model to change │
|
||||
│ directory: ~/code/codex3/codex-rs/tui │
|
||||
╰─────────────────────────────────────────────╯
|
||||
|
||||
To get started, describe a task or try one of these commands:
|
||||
|
||||
/init - create an AGENTS.md file with instructions for Codex
|
||||
/status - show current session configuration
|
||||
/approvals - choose what Codex can do without approval
|
||||
/model - choose what model and reasoning effort to use
|
||||
/review - review any changes and find issues
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/history_cell/update_available.rs
|
||||
expression: cell.display_string(52)
|
||||
---
|
||||
╭───────────────────────────────────────────────────────────────╮
|
||||
│ ✨Update available! 0.0.0 -> 2.3.4 │
|
||||
│ See https://github.com/openai/codex for installation options. │
|
||||
│ │
|
||||
│ See full release notes: │
|
||||
│ https://github.com/openai/codex/releases/latest │
|
||||
╰───────────────────────────────────────────────────────────────╯
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/history_cell/update_available.rs
|
||||
expression: cell.display_string(48)
|
||||
---
|
||||
╭─────────────────────────────────────────────────╮
|
||||
│ ✨Update available! 0.0.0 -> 2.3.4 │
|
||||
│ Run brew upgrade codex to update. │
|
||||
│ │
|
||||
│ See full release notes: │
|
||||
│ https://github.com/openai/codex/releases/latest │
|
||||
╰─────────────────────────────────────────────────╯
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/history_cell/update_available.rs
|
||||
expression: cell.display_string(48)
|
||||
---
|
||||
╭─────────────────────────────────────────────────╮
|
||||
│ ✨Update available! 0.0.0 -> 2.3.4 │
|
||||
│ Run brew upgrade codex to update. │
|
||||
│ │
|
||||
│ See full release notes: │
|
||||
│ https://github.com/openai/codex/releases/latest │
|
||||
╰─────────────────────────────────────────────────╯
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/history_cell/update_available.rs
|
||||
expression: cell.display_string(52)
|
||||
---
|
||||
╭───────────────────────────────────────────────────────────────╮
|
||||
│ ✨Update available! 0.0.0 -> 2.3.4 │
|
||||
│ See https://github.com/openai/codex for installation options. │
|
||||
│ │
|
||||
│ See full release notes: │
|
||||
│ https://github.com/openai/codex/releases/latest │
|
||||
╰───────────────────────────────────────────────────────────────╯
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
source: tui/src/history_cell/user.rs
|
||||
assertion_line: 63
|
||||
expression: rendered
|
||||
---
|
||||
› one two
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/history_cell/view_image.rs
|
||||
assertion_line: 102
|
||||
expression: cell.display_string(24)
|
||||
---
|
||||
• Viewed Image
|
||||
└ /repo/images/
|
||||
very/deep/path/to/
|
||||
output.png
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/history_cell/view_image.rs
|
||||
assertion_line: 93
|
||||
expression: cell.display_string(80)
|
||||
---
|
||||
• Viewed Image
|
||||
└ /repo/images/very/deep/path/to/output.png
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/history_cell/web_search.rs
|
||||
assertion_line: 95
|
||||
expression: cell.display_string(24)
|
||||
---
|
||||
🌐 find ratatui styling
|
||||
tips for codex tui
|
||||
with wrapping
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/history_cell/web_search.rs
|
||||
assertion_line: 88
|
||||
expression: cell.display_string(80)
|
||||
---
|
||||
🌐 find ratatui styling tips for codex tui with wrapping
|
||||
129
codex-rs/tui/src/history_cell/update_available.rs
Normal file
129
codex-rs/tui/src/history_cell/update_available.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use super::HistoryCell;
|
||||
use super::with_border_with_inner_width;
|
||||
use crate::update_action::UpdateAction;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Banner shown when a newer Codex CLI is available.
|
||||
///
|
||||
/// Renders a boxed notice with the current → latest version, a sparkle emoji header, and either a
|
||||
/// runnable update command (when the CLI knows how it was installed) or a link to release notes.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// ╭─────────────────────────────────╮
|
||||
/// │ ✨Update available! 1.0 -> 1.2.3 │
|
||||
/// │ Run brew upgrade codex to update.│
|
||||
/// ╰─────────────────────────────────╯
|
||||
/// ```
|
||||
#[cfg_attr(debug_assertions, allow(dead_code))]
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct UpdateAvailableHistoryCell {
|
||||
pub(crate) latest_version: String,
|
||||
pub(crate) update_action: Option<UpdateAction>,
|
||||
}
|
||||
|
||||
#[cfg_attr(debug_assertions, allow(dead_code))]
|
||||
impl UpdateAvailableHistoryCell {
|
||||
/// Build an update banner describing the latest version and how to upgrade.
|
||||
///
|
||||
/// `latest_version` is shown alongside the current CLI version; `update_action` controls
|
||||
/// whether we render a concrete command or fall back to a docs link.
|
||||
pub(crate) fn new(latest_version: String, update_action: Option<UpdateAction>) -> Self {
|
||||
Self {
|
||||
latest_version,
|
||||
update_action,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for UpdateAvailableHistoryCell {
|
||||
/// Render a boxed update notice with version delta and a follow-up action.
|
||||
///
|
||||
/// The header shows a cyan sparkle and “Update available!” plus `{current} -> {latest}`. The
|
||||
/// following line either displays a runnable command (if known) or a link to installation
|
||||
/// options, then a link to release notes. The content is wrapped inside a border sized to the
|
||||
/// available width minus outer padding.
|
||||
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![
|
||||
"✨".cyan().bold(),
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn renders_with_update_action_command() {
|
||||
let cell =
|
||||
UpdateAvailableHistoryCell::new("1.2.3".to_string(), Some(UpdateAction::BrewUpgrade));
|
||||
|
||||
let rendered = cell.display_string(80);
|
||||
|
||||
assert!(rendered.contains("Update available!"));
|
||||
assert!(rendered.contains("Run brew upgrade codex to update."));
|
||||
assert!(
|
||||
rendered.starts_with('╭') && rendered.contains('╯'),
|
||||
"banner should be boxed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_link_when_no_action_available() {
|
||||
let cell = UpdateAvailableHistoryCell::new("2.0.0".to_string(), None);
|
||||
|
||||
let rendered = cell.display_string(80);
|
||||
|
||||
assert!(rendered.contains("codex/releases/latest"));
|
||||
assert!(rendered.contains("See https://github.com/openai/codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_action_box_wraps() {
|
||||
let cell =
|
||||
UpdateAvailableHistoryCell::new("2.3.4".to_string(), Some(UpdateAction::BrewUpgrade));
|
||||
|
||||
assert_snapshot!(cell.display_string(48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_link_box_wraps() {
|
||||
let cell = UpdateAvailableHistoryCell::new("2.3.4".to_string(), None);
|
||||
|
||||
assert_snapshot!(cell.display_string(52));
|
||||
}
|
||||
}
|
||||
87
codex-rs/tui/src/history_cell/user.rs
Normal file
87
codex-rs/tui/src/history_cell/user.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use super::HistoryCell;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::style::user_message_style;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_lines;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Represents the text a human typed into the chat composer.
|
||||
///
|
||||
/// Used in the scrolling history view to show user input with a distinct prefix and background so
|
||||
/// it’s easy to tell apart from agent replies and tool output.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
///
|
||||
/// › hello there
|
||||
/// wrapped lines continue here
|
||||
///
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct UserHistoryCell {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Construct a user history entry from the raw chat input text.
|
||||
///
|
||||
/// Allows callers to pass the message directly without knowing about the cell internals; the render
|
||||
/// preserves the shaded background and `›` prefix that distinguish user input in the history list.
|
||||
pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell {
|
||||
UserHistoryCell { message }
|
||||
}
|
||||
|
||||
impl HistoryCell for UserHistoryCell {
|
||||
/// Render the user message with background shading, leading `›`, and vertical padding.
|
||||
///
|
||||
/// The content is wrapped to `width - LIVE_PREFIX_COLS - 1` (keeping a single-column right
|
||||
/// margin). A blank line is inserted above and below, all styled with `user_message_style` to
|
||||
/// give a subtle background. Wrapped lines are prefixed with a dim bold `› ` on the first line
|
||||
/// and two spaces on subsequent lines, preserving a hanging indent so the block reads like a
|
||||
/// quoted user prompt inside the history list.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
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 wrapped = word_wrap_lines(
|
||||
self.message.lines().map(|l| Line::from(l).style(style)),
|
||||
// Wrap algorithm matches textarea.rs.
|
||||
RtOptions::new(usize::from(wrap_width))
|
||||
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
|
||||
);
|
||||
|
||||
lines.push(Line::from("").style(style));
|
||||
lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into()));
|
||||
lines.push(Line::from("").style(style));
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wraps_and_prefixes_each_line_snapshot() {
|
||||
let msg = "one two three four five six seven";
|
||||
let cell = UserHistoryCell {
|
||||
message: msg.to_string(),
|
||||
};
|
||||
|
||||
// Small width to force wrapping more clearly. Effective wrap width is width-2 due to the ▌
|
||||
// prefix and trailing space.
|
||||
let width: u16 = 12;
|
||||
let rendered = cell.display_string(width);
|
||||
|
||||
insta::assert_snapshot!(rendered);
|
||||
}
|
||||
}
|
||||
106
codex-rs/tui/src/history_cell/view_image.rs
Normal file
106
codex-rs/tui/src/history_cell/view_image.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use super::HistoryCell;
|
||||
use crate::diff_render::display_path_for;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use textwrap::wrap;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// History entry indicating an image preview was opened from a tool call.
|
||||
///
|
||||
/// Displays the label `Viewed Image` and the image path relative to the session root so users can
|
||||
/// quickly confirm which artifact was opened. Paths wrap under a dim connector line so deep paths
|
||||
/// stay aligned.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// • Viewed Image
|
||||
/// └ /repo/images/
|
||||
/// very/deep/path/to/
|
||||
/// output.png
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ViewImageToolCallCell {
|
||||
path: PathBuf,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl ViewImageToolCallCell {
|
||||
/// Build a cell for an image opened at `path`, relativized against `cwd`.
|
||||
pub(crate) fn new(path: PathBuf, cwd: &Path) -> Self {
|
||||
Self {
|
||||
path,
|
||||
cwd: cwd.to_path_buf(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for ViewImageToolCallCell {
|
||||
/// Render the label and relative path with a connector that wraps under the arrow.
|
||||
///
|
||||
/// The leading bullet and label announce the action. The file path is dimmed and wrapped to the
|
||||
/// available width minus the `└ ` connector indentation, with subsequent lines padded by four
|
||||
/// spaces to keep the path visually grouped.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let display_path = display_path_for(&self.path, &self.cwd);
|
||||
let prefix = " └ ";
|
||||
let prefix_width = UnicodeWidthStr::width(prefix);
|
||||
let wrap_width = usize::from(width).saturating_sub(prefix_width).max(1);
|
||||
let wrapped = wrap(&display_path, wrap_width);
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(vec!["• ".dim(), "Viewed Image".bold()].into());
|
||||
for (idx, segment) in wrapped.into_iter().enumerate() {
|
||||
let prefix_str = if idx == 0 {
|
||||
" └ ".dim()
|
||||
} else {
|
||||
" ".dim()
|
||||
};
|
||||
lines.push(vec![prefix_str, segment.to_string().dim()].into());
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory wrapper used by `history_cell::mod` to create view-image cells.
|
||||
pub(crate) fn new_view_image_tool_call(path: PathBuf, cwd: &Path) -> ViewImageToolCallCell {
|
||||
ViewImageToolCallCell::new(path, cwd)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn renders_relative_path() {
|
||||
let cell = ViewImageToolCallCell::new(
|
||||
PathBuf::from("/repo/images/output.png"),
|
||||
Path::new("/repo"),
|
||||
);
|
||||
|
||||
let rendered = cell.display_string(80);
|
||||
assert!(rendered.contains("output.png"), "rendered: {rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_wide() {
|
||||
let cell = ViewImageToolCallCell::new(
|
||||
PathBuf::from("/repo/images/very/deep/path/to/output.png"),
|
||||
Path::new("/repo"),
|
||||
);
|
||||
assert_snapshot!(cell.display_string(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_narrow() {
|
||||
let cell = ViewImageToolCallCell::new(
|
||||
PathBuf::from("/repo/images/very/deep/path/to/output.png"),
|
||||
Path::new("/repo"),
|
||||
);
|
||||
assert_snapshot!(cell.display_string(24));
|
||||
}
|
||||
}
|
||||
88
codex-rs/tui/src/history_cell/web_search.rs
Normal file
88
codex-rs/tui/src/history_cell/web_search.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use super::HistoryCell;
|
||||
use super::padded_emoji;
|
||||
use ratatui::text::Line;
|
||||
use textwrap::wrap;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
/// Agent-issued web search entry shown in the history list.
|
||||
///
|
||||
/// Used when the agent triggers the web search tool so users can see the outbound query. Displays a
|
||||
/// padded globe emoji followed by the query text. Wrapped lines align under the text, preserving
|
||||
/// the emoji width so searches stand out without adding extra padding.
|
||||
///
|
||||
/// # Output
|
||||
///
|
||||
/// ```plain
|
||||
/// 🌐 find ratatui styling
|
||||
/// tips for codex tui
|
||||
/// with wrapping
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WebSearchCallCell {
|
||||
query: String,
|
||||
}
|
||||
|
||||
impl WebSearchCallCell {
|
||||
/// Create a web search cell for the given outbound query text.
|
||||
pub(crate) fn new(query: String) -> Self {
|
||||
Self { query }
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for WebSearchCallCell {
|
||||
/// Render the query with a globe prefix and wrapped continuation lines.
|
||||
///
|
||||
/// The globe is followed by a hair space so the emoji doesn’t crowd the text. Wrapped lines are
|
||||
/// indented by the emoji width to maintain alignment, and the text is wrapped against the
|
||||
/// available width minus the prefix.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let prefix = padded_emoji("🌐");
|
||||
let prefix_width = UnicodeWidthStr::width(prefix.as_str());
|
||||
let wrap_width = usize::from(width).saturating_sub(prefix_width).max(1);
|
||||
let wrapped = wrap(&self.query, wrap_width);
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
for (idx, segment) in wrapped.into_iter().enumerate() {
|
||||
if idx == 0 {
|
||||
lines.push(Line::from(format!("{prefix}{segment}")));
|
||||
} else {
|
||||
lines.push(Line::from(format!("{}{segment}", " ".repeat(prefix_width))));
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory hook used by the module re-export to build a web search cell.
|
||||
pub(crate) fn new_web_search_call(query: String) -> WebSearchCallCell {
|
||||
WebSearchCallCell::new(query)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn formats_query_with_globe_prefix() {
|
||||
let cell = WebSearchCallCell::new("two words".into());
|
||||
|
||||
assert_eq!(
|
||||
cell.display_string(80),
|
||||
format!("{}two words", padded_emoji("🌐"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_wide() {
|
||||
let cell =
|
||||
WebSearchCallCell::new("find ratatui styling tips for codex tui with wrapping".into());
|
||||
assert_snapshot!(cell.display_string(80));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_narrow() {
|
||||
let cell =
|
||||
WebSearchCallCell::new("find ratatui styling tips for codex tui with wrapping".into());
|
||||
assert_snapshot!(cell.display_string(24));
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Updated Plan
|
||||
└ I’ll update Grafana call
|
||||
error handling by adding
|
||||
retries and clearer
|
||||
messages when the backend is
|
||||
unreachable.
|
||||
✔ Investigate existing error
|
||||
paths and logging around
|
||||
HTTP timeouts
|
||||
□ Harden Grafana client
|
||||
error handling with retry/
|
||||
backoff and user‑friendly
|
||||
messages
|
||||
□ Add tests for transient
|
||||
failure scenarios and
|
||||
surfacing to the UI
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
source: tui/src/history_cell.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Updated Plan
|
||||
└ □ Define error taxonomy
|
||||
□ Implement mapping to user messages
|
||||
Reference in New Issue
Block a user