From 18123804ce5a471205904827227afb8197732d60 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Fri, 21 Nov 2025 00:10:23 -0800 Subject: [PATCH] Refactor history_cell for clarity, intent, and safety - Reorganized history cells into dedicated modules, moving factory constructors alongside implementations so mod.rs mostly re-exports shared helpers. - Documented every cell and method with clear intent: where it appears, what it shows, how it renders (styling, wrapping, padding), plus # Output examples to convey expected layout without reading code. - Added comprehensive characterization tests and aligned insta snapshots per cell, using realistic data and wrapped/unwrapped scenarios to protect rendering behavior during future changes. - Ran fmt and scoped clippy fixes; cargo test -p codex-tui passes with updated snapshots. --- codex-rs/Cargo.lock | 8 +- codex-rs/tui/Cargo.toml | 1 + ...tests__interrupted_turn_error_message.snap | 4 +- codex-rs/tui/src/exec_cell/model.rs | 32 + codex-rs/tui/src/exec_cell/render.rs | 28 +- codex-rs/tui/src/history_cell.rs | 2375 ----------------- codex-rs/tui/src/history_cell/agent.rs | 93 + .../tui/src/history_cell/approval_decision.rs | 187 ++ codex-rs/tui/src/history_cell/composite.rs | 78 + codex-rs/tui/src/history_cell/deprecation.rs | 81 + codex-rs/tui/src/history_cell/exec.rs | 348 +++ .../tui/src/history_cell/final_separator.rs | 85 + codex-rs/tui/src/history_cell/mcp.rs | 824 ++++++ codex-rs/tui/src/history_cell/mod.rs | 269 ++ codex-rs/tui/src/history_cell/notice.rs | 59 + codex-rs/tui/src/history_cell/patch.rs | 173 ++ .../src/history_cell/patch_apply_failure.rs | 93 + codex-rs/tui/src/history_cell/plain.rs | 60 + codex-rs/tui/src/history_cell/plan.rs | 138 + .../tui/src/history_cell/prefixed_wrapped.rs | 110 + .../tui/src/history_cell/reasoning_summary.rs | 289 ++ .../tui/src/history_cell/review_status.rs | 56 + codex-rs/tui/src/history_cell/session.rs | 421 +++ ...tests__continuation_line_omits_bullet.snap | 6 + ...ent__tests__wraps_with_hanging_indent.snap | 7 + ...cell__approval_decision__tests__abort.snap | 7 + ...l__approval_decision__tests__approved.snap | 7 + ...decision__tests__approved_for_session.snap | 7 + ...ell__approval_decision__tests__denied.snap | 7 + ...tests__snapshot_with_wrapped_children.snap | 7 + ...tests__deprecation_with_details_wraps.snap | 9 + ..._renders_summary_only_when_no_details.snap | 6 + ..._tests__coalesced_reads_dedupe_names.snap} | 2 +- ...oalesces_reads_across_multiple_calls.snap} | 2 +- ...ces_sequential_reads_within_one_call.snap} | 3 +- ...oth_lines_wrap_with_correct_prefixes.snap} | 2 +- ...t_wrap_uses_branch_then_eight_spaces.snap} | 2 +- ...ith_extra_indent_on_subsequent_lines.snap} | 2 +- ..._cell_multiline_with_stderr_snapshot.snap} | 2 +- ...ingle_line_command_compact_when_fits.snap} | 2 +- ...d_wraps_with_four_space_continuation.snap} | 2 +- ...r_tail_more_than_five_lines_snapshot.snap} | 2 +- ...arator__tests__separator_with_elapsed.snap | 5 + ...tor__tests__separator_without_elapsed.snap | 5 + ...parator__tests__snapshot_with_elapsed.snap | 5 + ...ator__tests__snapshot_without_elapsed.snap | 5 + ...ll__mcp__tests__active_call_snapshot.snap} | 4 +- ...tests__completed_call_error_snapshot.snap} | 4 +- ...all_multiple_outputs_inline_snapshot.snap} | 4 +- ...leted_call_multiple_outputs_snapshot.snap} | 4 +- ...sts__completed_call_success_snapshot.snap} | 4 +- ...pleted_call_wrapped_outputs_snapshot.snap} | 4 +- ..._tools_output_masks_sensitive_values.snap} | 4 +- ...l__notice__tests__error_event_renders.snap | 6 + ...ll__notice__tests__info_event_renders.snap | 7 + ...l__notice__tests__warning_event_wraps.snap | 7 + ...sts__multiple_files_summary_and_moves.snap | 15 + ..._tests__single_file_patch_wraps_hunks.snap | 9 + ...shot_multiple_files_summary_and_moves.snap | 15 + ...sts__snapshot_single_file_wraps_hunks.snap | 9 + ...apply_failure__tests__snapshot_narrow.snap | 12 + ...h_apply_failure__tests__snapshot_wide.snap | 12 + ...__tests__renders_multiline_plain_text.snap | 6 + ...in__tests__snapshot_plain_passthrough.snap | 6 + ...ests__renders_checkbox_steps_and_note.snap | 10 + ...ests__shows_placeholder_when_no_steps.snap | 7 + ...ed__tests__snapshot_prefixed_wrapping.snap | 7 + ...ing_message_wraps_with_hanging_indent.snap | 7 + ...sts__snapshot_visible_summary_wrapped.snap | 7 + ...summary__tests__visible_summary_wraps.snap | 7 + ...s__tests__long_status_message_renders.snap | 5 + ...w_status__tests__snapshot_long_status.snap | 5 + ...ion__tests__session_header_full_width.snap | 10 + ...s__session_header_truncates_directory.snap | 10 + ...on__tests__session_info_includes_help.snap | 18 + ...t_session_header_narrow_truncates_dir.snap | 10 + ...__tests__snapshot_session_header_wide.snap | 10 + ...ests__snapshot_session_info_with_help.snap | 18 + ...shot_with_link_when_unknown_installer.snap | 11 + ...__snapshot_with_update_action_wrapped.snap | 11 + ...lable__tests__update_action_box_wraps.snap | 11 + ...ailable__tests__update_link_box_wraps.snap | 11 + ...raps_and_prefixes_each_line_snapshot.snap} | 3 +- ...l__view_image__tests__snapshot_narrow.snap | 9 + ...ell__view_image__tests__snapshot_wide.snap | 7 + ...l__web_search__tests__snapshot_narrow.snap | 8 + ...ell__web_search__tests__snapshot_wide.snap | 6 + .../tui/src/history_cell/update_available.rs | 129 + codex-rs/tui/src/history_cell/user.rs | 87 + codex-rs/tui/src/history_cell/view_image.rs | 106 + codex-rs/tui/src/history_cell/web_search.rs | 88 + ...pdate_with_note_and_wrapping_snapshot.snap | 20 - ...ts__plan_update_without_note_snapshot.snap | 7 - 93 files changed, 4262 insertions(+), 2431 deletions(-) delete mode 100644 codex-rs/tui/src/history_cell.rs create mode 100644 codex-rs/tui/src/history_cell/agent.rs create mode 100644 codex-rs/tui/src/history_cell/approval_decision.rs create mode 100644 codex-rs/tui/src/history_cell/composite.rs create mode 100644 codex-rs/tui/src/history_cell/deprecation.rs create mode 100644 codex-rs/tui/src/history_cell/exec.rs create mode 100644 codex-rs/tui/src/history_cell/final_separator.rs create mode 100644 codex-rs/tui/src/history_cell/mcp.rs create mode 100644 codex-rs/tui/src/history_cell/mod.rs create mode 100644 codex-rs/tui/src/history_cell/notice.rs create mode 100644 codex-rs/tui/src/history_cell/patch.rs create mode 100644 codex-rs/tui/src/history_cell/patch_apply_failure.rs create mode 100644 codex-rs/tui/src/history_cell/plain.rs create mode 100644 codex-rs/tui/src/history_cell/plan.rs create mode 100644 codex-rs/tui/src/history_cell/prefixed_wrapped.rs create mode 100644 codex-rs/tui/src/history_cell/reasoning_summary.rs create mode 100644 codex-rs/tui/src/history_cell/review_status.rs create mode 100644 codex-rs/tui/src/history_cell/session.rs create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__agent__tests__continuation_line_omits_bullet.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__agent__tests__wraps_with_hanging_indent.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__approval_decision__tests__abort.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__approval_decision__tests__approved.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__approval_decision__tests__approved_for_session.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__approval_decision__tests__denied.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__composite__tests__snapshot_with_wrapped_children.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__deprecation__tests__deprecation_with_details_wraps.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__deprecation__tests__renders_summary_only_when_no_details.snap rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__coalesced_reads_dedupe_names.snap => history_cell/snapshots/codex_tui__history_cell__exec__tests__coalesced_reads_dedupe_names.snap} (66%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__coalesces_reads_across_multiple_calls.snap => history_cell/snapshots/codex_tui__history_cell__exec__tests__coalesces_reads_across_multiple_calls.snap} (75%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__coalesces_sequential_reads_within_one_call.snap => history_cell/snapshots/codex_tui__history_cell__exec__tests__coalesces_sequential_reads_within_one_call.snap} (69%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap => history_cell/snapshots/codex_tui__history_cell__exec__tests__multiline_command_both_lines_wrap_with_correct_prefixes.snap} (79%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap => history_cell/snapshots/codex_tui__history_cell__exec__tests__multiline_command_without_wrap_uses_branch_then_eight_spaces.snap} (68%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap => history_cell/snapshots/codex_tui__history_cell__exec__tests__multiline_command_wraps_with_extra_indent_on_subsequent_lines.snap} (75%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__ran_cell_multiline_with_stderr_snapshot.snap => history_cell/snapshots/codex_tui__history_cell__exec__tests__ran_cell_multiline_with_stderr_snapshot.snap} (83%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__single_line_command_compact_when_fits.snap => history_cell/snapshots/codex_tui__history_cell__exec__tests__single_line_command_compact_when_fits.snap} (63%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__single_line_command_wraps_with_four_space_continuation.snap => history_cell/snapshots/codex_tui__history_cell__exec__tests__single_line_command_wraps_with_four_space_continuation.snap} (76%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__stderr_tail_more_than_five_lines_snapshot.snap => history_cell/snapshots/codex_tui__history_cell__exec__tests__stderr_tail_more_than_five_lines_snapshot.snap} (73%) create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__final_separator__tests__separator_with_elapsed.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__final_separator__tests__separator_without_elapsed.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__final_separator__tests__snapshot_with_elapsed.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__final_separator__tests__snapshot_without_elapsed.snap rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__active_mcp_tool_call_snapshot.snap => history_cell/snapshots/codex_tui__history_cell__mcp__tests__active_call_snapshot.snap} (63%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_error_snapshot.snap => history_cell/snapshots/codex_tui__history_cell__mcp__tests__completed_call_error_snapshot.snap} (69%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_inline_snapshot.snap => history_cell/snapshots/codex_tui__history_cell__mcp__tests__completed_call_multiple_outputs_inline_snapshot.snap} (75%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_multiple_outputs_snapshot.snap => history_cell/snapshots/codex_tui__history_cell__mcp__tests__completed_call_multiple_outputs_snapshot.snap} (80%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_success_snapshot.snap => history_cell/snapshots/codex_tui__history_cell__mcp__tests__completed_call_success_snapshot.snap} (71%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__completed_mcp_tool_call_wrapped_outputs_snapshot.snap => history_cell/snapshots/codex_tui__history_cell__mcp__tests__completed_call_wrapped_outputs_snapshot.snap} (85%) rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__mcp_tools_output_masks_sensitive_values.snap => history_cell/snapshots/codex_tui__history_cell__mcp__tests__tools_output_masks_sensitive_values.snap} (90%) create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__notice__tests__error_event_renders.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__notice__tests__info_event_renders.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__notice__tests__warning_event_wraps.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__patch__tests__multiple_files_summary_and_moves.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__patch__tests__single_file_patch_wraps_hunks.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__patch__tests__snapshot_multiple_files_summary_and_moves.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__patch__tests__snapshot_single_file_wraps_hunks.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__patch_apply_failure__tests__snapshot_narrow.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__patch_apply_failure__tests__snapshot_wide.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__plain__tests__renders_multiline_plain_text.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__plain__tests__snapshot_plain_passthrough.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__plan__tests__renders_checkbox_steps_and_note.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__plan__tests__shows_placeholder_when_no_steps.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__prefixed_wrapped__tests__snapshot_prefixed_wrapping.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__prefixed_wrapped__tests__warning_message_wraps_with_hanging_indent.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__reasoning_summary__tests__snapshot_visible_summary_wrapped.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__reasoning_summary__tests__visible_summary_wraps.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__review_status__tests__long_status_message_renders.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__review_status__tests__snapshot_long_status.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__session__tests__session_header_full_width.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__session__tests__session_header_truncates_directory.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__session__tests__session_info_includes_help.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__session__tests__snapshot_session_header_narrow_truncates_dir.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__session__tests__snapshot_session_header_wide.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__session__tests__snapshot_session_info_with_help.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__update_available__tests__snapshot_with_link_when_unknown_installer.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__update_available__tests__snapshot_with_update_action_wrapped.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__update_available__tests__update_action_box_wraps.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__update_available__tests__update_link_box_wraps.snap rename codex-rs/tui/src/{snapshots/codex_tui__history_cell__tests__user_history_cell_wraps_and_prefixes_each_line_snapshot.snap => history_cell/snapshots/codex_tui__history_cell__user__tests__wraps_and_prefixes_each_line_snapshot.snap} (56%) create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__view_image__tests__snapshot_narrow.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__view_image__tests__snapshot_wide.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__web_search__tests__snapshot_narrow.snap create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__web_search__tests__snapshot_wide.snap create mode 100644 codex-rs/tui/src/history_cell/update_available.rs create mode 100644 codex-rs/tui/src/history_cell/user.rs create mode 100644 codex-rs/tui/src/history_cell/view_image.rs create mode 100644 codex-rs/tui/src/history_cell/web_search.rs delete mode 100644 codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_with_note_and_wrapping_snapshot.snap delete mode 100644 codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__plan_update_without_note_snapshot.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 01abeee6af..5b3e7b6a93 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 01fef9cc74..22e7a0946e 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -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 } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap index 60715e581e..28b6521f1b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__interrupted_turn_error_message.snap @@ -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. diff --git a/codex-rs/tui/src/exec_cell/model.rs b/codex-rs/tui/src/exec_cell/model.rs index 76316968c6..e13ccd2210 100644 --- a/codex-rs/tui/src/exec_cell/model.rs +++ b/codex-rs/tui/src/exec_cell/model.rs @@ -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, } +/// 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, @@ -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 { 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 { 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) } diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 3e434138d4..a23094cf23 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -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, @@ -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, } +/// 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, animations_enabled: bool) -> Span<'static> { if !animations_enabled { return "•".dim(); @@ -189,6 +196,7 @@ pub(crate) fn spinner(start_time: Option, 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> { 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> { let mut lines: Vec> = 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> { let mut out: Vec> = 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> { 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> { 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, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs deleted file mode 100644 index 02ab0d243b..0000000000 --- a/codex-rs/tui/src/history_cell.rs +++ /dev/null @@ -1,2375 +0,0 @@ -use crate::diff_render::create_diff_summary; -use crate::diff_render::display_path_for; -use crate::exec_cell::CommandOutput; -use crate::exec_cell::OutputLinesParams; -use crate::exec_cell::TOOL_CALL_MAX_LINES; -use crate::exec_cell::output_lines; -use crate::exec_cell::spinner; -use crate::exec_command::relativize_to_home; -use crate::exec_command::strip_bash_lc_and_escape; -use crate::markdown::append_markdown; -use crate::render::line_utils::line_to_static; -use crate::render::line_utils::prefix_lines; -use crate::render::line_utils::push_owned_lines; -use crate::render::renderable::Renderable; -use crate::style::user_message_style; -use crate::text_formatting::format_and_truncate_tool_result; -use crate::text_formatting::truncate_text; -use crate::ui_consts::LIVE_PREFIX_COLS; -use crate::update_action::UpdateAction; -use crate::version::CODEX_CLI_VERSION; -use crate::wrapping::RtOptions; -use crate::wrapping::word_wrap_line; -use crate::wrapping::word_wrap_lines; -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::config::types::ReasoningSummaryFormat; -use codex_core::protocol::FileChange; -use codex_core::protocol::McpAuthStatus; -use codex_core::protocol::McpInvocation; -use codex_core::protocol::SessionConfiguredEvent; -use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::plan_tool::PlanItemArg; -use codex_protocol::plan_tool::StepStatus; -use codex_protocol::plan_tool::UpdatePlanArgs; -use image::DynamicImage; -use image::ImageReader; -use mcp_types::EmbeddedResourceResource; -use mcp_types::Resource; -use mcp_types::ResourceLink; -use mcp_types::ResourceTemplate; -use ratatui::prelude::*; -use ratatui::style::Modifier; -use ratatui::style::Style; -use ratatui::style::Styled; -use ratatui::style::Stylize; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Wrap; -use std::any::Any; -use std::collections::HashMap; -use std::io::Cursor; -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; -use std::time::Instant; -use tracing::error; -use unicode_width::UnicodeWidthStr; - -/// Represents an event to display in the conversation history. Returns its -/// `Vec>` representation to make it easier to display in a -/// scrollable list. -pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any { - fn display_lines(&self, width: u16) -> Vec>; - - 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) - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.display_lines(width) - } - - 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) - } - - fn is_stream_continuation(&self) -> bool { - false - } -} - -impl Renderable for Box { - 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 - } -} - -#[derive(Debug)] -pub(crate) struct UserHistoryCell { - pub message: String, -} - -impl HistoryCell for UserHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = 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 - } -} - -#[derive(Debug)] -pub(crate) struct ReasoningSummaryCell { - _header: String, - content: String, - transcript_only: bool, -} - -impl ReasoningSummaryCell { - pub(crate) fn new(header: String, content: String, transcript_only: bool) -> Self { - Self { - _header: header, - content, - transcript_only, - } - } - - fn lines(&self, width: u16) -> Vec> { - let mut lines: Vec> = 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::>(); - - word_wrap_lines( - &summary_lines, - RtOptions::new(width as usize) - .initial_indent("• ".dim().into()) - .subsequent_indent(" ".into()), - ) - } -} - -impl HistoryCell for ReasoningSummaryCell { - fn display_lines(&self, width: u16) -> Vec> { - 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> { - self.lines(width) - } - - fn desired_transcript_height(&self, width: u16) -> u16 { - self.lines(width).len() as u16 - } -} - -#[derive(Debug)] -pub(crate) struct AgentMessageCell { - lines: Vec>, - is_first_line: bool, -} - -impl AgentMessageCell { - pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { - Self { - lines, - is_first_line, - } - } -} - -impl HistoryCell for AgentMessageCell { - fn display_lines(&self, width: u16) -> Vec> { - word_wrap_lines( - &self.lines, - RtOptions::new(width as usize) - .initial_indent(if self.is_first_line { - "• ".dim().into() - } else { - " ".into() - }) - .subsequent_indent(" ".into()), - ) - } - - fn is_stream_continuation(&self) -> bool { - !self.is_first_line - } -} - -#[derive(Debug)] -pub(crate) struct PlainHistoryCell { - lines: Vec>, -} - -impl PlainHistoryCell { - pub(crate) fn new(lines: Vec>) -> Self { - Self { lines } - } -} - -impl HistoryCell for PlainHistoryCell { - fn display_lines(&self, _width: u16) -> Vec> { - self.lines.clone() - } -} - -#[cfg_attr(debug_assertions, allow(dead_code))] -#[derive(Debug)] -pub(crate) struct UpdateAvailableHistoryCell { - latest_version: String, - update_action: Option, -} - -#[cfg_attr(debug_assertions, allow(dead_code))] -impl UpdateAvailableHistoryCell { - pub(crate) fn new(latest_version: String, update_action: Option) -> Self { - Self { - latest_version, - update_action, - } - } -} - -impl HistoryCell for UpdateAvailableHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - use ratatui_macros::line; - use ratatui_macros::text; - let update_instruction = if let Some(update_action) = self.update_action { - line!["Run ", update_action.command_str().cyan(), " to update."] - } else { - line![ - "See ", - "https://github.com/openai/codex".cyan().underlined(), - " for installation options." - ] - }; - - let content = text![ - line![ - padded_emoji("✨").bold().cyan(), - "Update available!".bold().cyan(), - " ", - format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), - ], - update_instruction, - "", - "See full release notes:", - "https://github.com/openai/codex/releases/latest" - .cyan() - .underlined(), - ]; - - let inner_width = content - .width() - .min(usize::from(width.saturating_sub(4))) - .max(1); - with_border_with_inner_width(content.lines, inner_width) - } -} - -#[derive(Debug)] -pub(crate) struct PrefixedWrappedHistoryCell { - text: Text<'static>, - initial_prefix: Line<'static>, - subsequent_prefix: Line<'static>, -} - -impl PrefixedWrappedHistoryCell { - pub(crate) fn new( - text: impl Into>, - initial_prefix: impl Into>, - subsequent_prefix: impl Into>, - ) -> Self { - Self { - text: text.into(), - initial_prefix: initial_prefix.into(), - subsequent_prefix: subsequent_prefix.into(), - } - } -} - -impl HistoryCell for PrefixedWrappedHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - 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 - } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width).len() as u16 - } -} - -fn truncate_exec_snippet(full_cmd: &str) -> String { - let mut snippet = match full_cmd.split_once('\n') { - Some((first, _)) => format!("{first} ..."), - None => full_cmd.to_string(), - }; - snippet = truncate_text(&snippet, 80); - snippet -} - -fn exec_snippet(command: &[String]) -> String { - let full_cmd = strip_bash_lc_and_escape(command); - truncate_exec_snippet(&full_cmd) -} - -pub fn new_approval_decision_cell( - command: Vec, - decision: codex_core::protocol::ReviewDecision, -) -> Box { - use codex_core::protocol::ReviewDecision::*; - - let (symbol, summary): (Span<'static>, Vec>) = 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, - ], - ) - } - }; - - Box::new(PrefixedWrappedHistoryCell::new( - Line::from(summary), - symbol, - " ", - )) -} - -/// Cyan history cell line showing the current review status. -pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell { - PlainHistoryCell { - lines: vec![Line::from(message.cyan())], - } -} - -#[derive(Debug)] -pub(crate) struct PatchHistoryCell { - changes: HashMap, - cwd: PathBuf, -} - -impl HistoryCell for PatchHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - create_diff_summary(&self.changes, &self.cwd, width as usize) - } -} - -#[derive(Debug)] -struct CompletedMcpToolCallWithImageOutput { - _image: DynamicImage, -} -impl HistoryCell for CompletedMcpToolCallWithImageOutput { - fn display_lines(&self, _width: u16) -> Vec> { - vec!["tool result (image output)".into()] - } -} - -pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value - -pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { - if width < 4 { - return None; - } - let inner_width = std::cmp::min(width.saturating_sub(4) as usize, max_inner_width); - Some(inner_width) -} - -/// Render `lines` inside a border sized to the widest span in the content. -pub(crate) fn with_border(lines: Vec>) -> Vec> { - 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>, - inner_width: usize, -) -> Vec> { - with_border_internal(lines, Some(inner_width)) -} - -fn with_border_internal( - lines: Vec>, - forced_inner_width: Option, -) -> Vec> { - let max_line_width = lines - .iter() - .map(|line| { - line.iter() - .map(|span| UnicodeWidthStr::width(span.content.as_ref())) - .sum::() - }) - .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> = Vec::with_capacity(span_count + 4); - spans.push(Span::from("│ ").dim()); - spans.extend(line.into_iter()); - if used_width < content_width { - spans.push(Span::from(" ".repeat(content_width - used_width)).dim()); - } - spans.push(Span::from(" │").dim()); - out.push(Line::from(spans)); - } - - out.push(vec![format!("╰{}╯", "─".repeat(border_inner_width)).dim()].into()); - - out -} - -/// Return the emoji followed by a hair space (U+200A). -/// Using only the hair space avoids excessive padding after the emoji while -/// still providing a small visual gap across terminals. -pub(crate) fn padded_emoji(emoji: &str) -> String { - format!("{emoji}\u{200A}") -} - -#[derive(Debug)] -pub struct SessionInfoCell(CompositeHistoryCell); - -impl HistoryCell for SessionInfoCell { - fn display_lines(&self, width: u16) -> Vec> { - self.0.display_lines(width) - } - - fn desired_height(&self, width: u16) -> u16 { - self.0.desired_height(width) - } - - fn transcript_lines(&self, width: u16) -> Vec> { - self.0.transcript_lines(width) - } -} - -pub(crate) fn new_session_info( - config: &Config, - event: SessionConfiguredEvent, - is_first_event: bool, -) -> SessionInfoCell { - let SessionConfiguredEvent { - model, - reasoning_effort, - .. - } = event; - SessionInfoCell(if is_first_event { - // Header box rendered as history (so it appears at the very top) - let header = SessionHeaderHistoryCell::new( - model, - reasoning_effort, - config.cwd.clone(), - crate::version::CODEX_CLI_VERSION, - ); - - // Help lines below the header (new copy and list) - let help_lines: Vec> = 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(), - ]), - ]; - - CompositeHistoryCell { - parts: vec![ - Box::new(header), - Box::new(PlainHistoryCell { lines: help_lines }), - ], - } - } else if config.model == model { - CompositeHistoryCell { parts: vec![] } - } else { - let lines = vec![ - "model changed:".magenta().bold().into(), - format!("requested: {}", config.model).into(), - format!("used: {model}").into(), - ]; - CompositeHistoryCell { - parts: vec![Box::new(PlainHistoryCell { lines })], - } - }) -} - -pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { - UserHistoryCell { message } -} - -#[derive(Debug)] -struct SessionHeaderHistoryCell { - version: &'static str, - model: String, - reasoning_effort: Option, - directory: PathBuf, -} - -impl SessionHeaderHistoryCell { - fn new( - model: String, - reasoning_effort: Option, - directory: PathBuf, - version: &'static str, - ) -> Self { - Self { - version, - model, - reasoning_effort, - directory, - } - } - - fn format_directory(&self, max_width: Option) -> String { - Self::format_directory_inner(&self.directory, max_width) - } - - fn format_directory_inner(directory: &Path, max_width: Option) -> String { - let formatted = if let Some(rel) = relativize_to_home(directory) { - if rel.as_os_str().is_empty() { - "~".to_string() - } else { - format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) - } - } else { - directory.display().to_string() - }; - - if let Some(max_width) = max_width { - if max_width == 0 { - return String::new(); - } - if UnicodeWidthStr::width(formatted.as_str()) > max_width { - return crate::text_formatting::center_truncate_path(&formatted, max_width); - } - } - - formatted - } - - fn reasoning_label(&self) -> Option<&'static str> { - self.reasoning_effort.map(|effort| match effort { - ReasoningEffortConfig::Minimal => "minimal", - ReasoningEffortConfig::Low => "low", - ReasoningEffortConfig::Medium => "medium", - ReasoningEffortConfig::High => "high", - ReasoningEffortConfig::XHigh => "xhigh", - ReasoningEffortConfig::None => "none", - }) - } -} - -impl HistoryCell for SessionHeaderHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH) else { - return Vec::new(); - }; - - let make_row = |spans: Vec>| Line::from(spans); - - // Title line rendered inside the box: ">_ OpenAI Codex (vX)" - let title_spans: Vec> = 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:> = 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:>, -} - -impl CompositeHistoryCell { - pub(crate) fn new(parts: Vec>) -> Self { - Self { parts } - } -} - -impl HistoryCell for CompositeHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { - let mut out: Vec> = Vec::new(); - let mut first = true; - for part in &self.parts { - let mut lines = part.display_lines(width); - if !lines.is_empty() { - if !first { - out.push(Line::from("")); - } - out.append(&mut lines); - first = false; - } - } - out - } -} - -#[derive(Debug)] -pub(crate) struct McpToolCallCell { - call_id: String, - invocation: McpInvocation, - start_time: Instant, - duration: Option, - result: Option>, - 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, - ) -> Option> { - let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result) - .map(|cell| Box::new(cell) as Box); - self.duration = Some(duration); - self.result = Some(result); - image_cell - } - - fn success(&self) -> Option { - match self.result.as_ref() { - Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)), - Some(Err(_)) => Some(false), - None => None, - } - } - - pub(crate) fn mark_failed(&mut self) { - let elapsed = self.start_time.elapsed(); - self.duration = Some(elapsed); - self.result = Some(Err("interrupted".to_string())); - } - - fn render_content_block(block: &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(_) => "".to_string(), - mcp_types::ContentBlock::AudioContent(_) => "