diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a97948f3ea..92384dfb94 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -64,6 +64,11 @@ pub(crate) struct App<'a> { pending_history_lines: Vec>, enhanced_keys_supported: bool, + + // UI options passed from CLI + live_rows: u16, + overlay_wrap: bool, + preview_max_cols: Option, } /// Aggregate parameters needed to create a `ChatWidget`, as creation may be @@ -74,6 +79,9 @@ pub(crate) struct ChatWidgetArgs { initial_prompt: Option, initial_images: Vec, enhanced_keys_supported: bool, + live_rows: u16, + overlay_wrap: bool, + preview_max_cols: Option, } impl App<'_> { @@ -82,6 +90,9 @@ impl App<'_> { initial_prompt: Option, initial_images: Vec, show_trust_screen: bool, + live_rows: u16, + overlay_wrap: bool, + preview_max_cols: Option, ) -> Self { let (app_event_tx, app_event_rx) = channel(); let app_event_tx = AppEventSender::new(app_event_tx); @@ -139,6 +150,9 @@ impl App<'_> { initial_prompt, initial_images, enhanced_keys_supported, + live_rows, + overlay_wrap, + preview_max_cols, }; AppState::Onboarding { screen: OnboardingScreen::new(OnboardingScreenArgs { @@ -157,6 +171,9 @@ impl App<'_> { initial_prompt, initial_images, enhanced_keys_supported, + live_rows, + overlay_wrap, + preview_max_cols, ); AppState::Chat { widget: Box::new(chat_widget), @@ -173,6 +190,9 @@ impl App<'_> { file_search, pending_redraw, enhanced_keys_supported, + live_rows, + overlay_wrap, + preview_max_cols, } } @@ -319,6 +339,9 @@ impl App<'_> { None, Vec::new(), self.enhanced_keys_supported, + self.live_rows, + self.overlay_wrap, + self.preview_max_cols, )); self.app_state = AppState::Chat { widget: new_widget }; self.app_event_tx.send(AppEvent::RequestRedraw); @@ -426,6 +449,9 @@ impl App<'_> { enhanced_keys_supported, initial_images, initial_prompt, + live_rows, + overlay_wrap, + preview_max_cols, }) => { self.app_state = AppState::Chat { widget: Box::new(ChatWidget::new( @@ -434,6 +460,9 @@ impl App<'_> { initial_prompt, initial_images, enhanced_keys_supported, + live_rows, + overlay_wrap, + preview_max_cols, )), } } diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index 8e9ff6d936..5deac931cb 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -100,6 +100,7 @@ mod tests { app_event_tx: AppEventSender::new(tx_raw2), has_input_focus: true, enhanced_keys_supported: false, + live_ring_wrap: false, }); assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane)); assert!(view.queue.is_empty()); diff --git a/codex-rs/tui/src/bottom_pane/live_ring_widget.rs b/codex-rs/tui/src/bottom_pane/live_ring_widget.rs index 13f91acc5d..fb4d4a46d0 100644 --- a/codex-rs/tui/src/bottom_pane/live_ring_widget.rs +++ b/codex-rs/tui/src/bottom_pane/live_ring_widget.rs @@ -8,6 +8,7 @@ use ratatui::widgets::WidgetRef; pub(crate) struct LiveRingWidget { max_rows: u16, rows: Vec>, // newest at the end + wrap: bool, } impl LiveRingWidget { @@ -15,6 +16,7 @@ impl LiveRingWidget { Self { max_rows: 3, rows: Vec::new(), + wrap: false, } } @@ -26,6 +28,10 @@ impl LiveRingWidget { self.rows = rows; } + pub fn set_wrap(&mut self, wrap: bool) { + self.wrap = wrap; + } + pub fn desired_height(&self, _width: u16) -> u16 { let len = self.rows.len() as u16; len.min(self.max_rows) @@ -39,7 +45,10 @@ impl WidgetRef for LiveRingWidget { } let visible = self.rows.len().saturating_sub(self.max_rows as usize); let slice = &self.rows[visible..]; - let para = Paragraph::new(slice.to_vec()); + let mut para = Paragraph::new(slice.to_vec()); + if self.wrap { + para = para.wrap(ratatui::widgets::Wrap { trim: false }); + } para.render_ref(area, buf); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 0c8610470c..4e946aa906 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -61,6 +61,9 @@ pub(crate) struct BottomPane<'a> { /// container used during development before we wire it to ChatWidget events. live_ring: Option, + /// Whether the live ring should soft-wrap to the terminal width. + live_ring_wrap: bool, + /// True if the active view is the StatusIndicatorView that replaces the /// composer during a running task. status_view_active: bool, @@ -70,6 +73,7 @@ pub(crate) struct BottomPaneParams { pub(crate) app_event_tx: AppEventSender, pub(crate) has_input_focus: bool, pub(crate) enhanced_keys_supported: bool, + pub(crate) live_ring_wrap: bool, } impl BottomPane<'_> { @@ -89,6 +93,7 @@ impl BottomPane<'_> { ctrl_c_quit_hint: false, live_status: None, live_ring: None, + live_ring_wrap: params.live_ring_wrap, status_view_active: false, } } @@ -357,6 +362,7 @@ impl BottomPane<'_> { pub(crate) fn set_live_ring_rows(&mut self, max_rows: u16, rows: Vec>) { let mut w = live_ring_widget::LiveRingWidget::new(); w.set_max_rows(max_rows); + w.set_wrap(self.live_ring_wrap); w.set_rows(rows); self.live_ring = Some(w); } @@ -459,6 +465,7 @@ mod tests { app_event_tx: tx, has_input_focus: true, enhanced_keys_supported: false, + live_ring_wrap: false, }); pane.push_approval_request(exec_request()); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); @@ -474,6 +481,7 @@ mod tests { app_event_tx: tx, has_input_focus: true, enhanced_keys_supported: false, + live_ring_wrap: false, }); // Provide 4 rows with max_rows=3; only the last 3 should be visible. @@ -511,6 +519,7 @@ mod tests { app_event_tx: tx, has_input_focus: true, enhanced_keys_supported: false, + live_ring_wrap: false, }); // Simulate task running which replaces composer with the status indicator. @@ -572,6 +581,7 @@ mod tests { app_event_tx: tx, has_input_focus: true, enhanced_keys_supported: false, + live_ring_wrap: false, }); // Create an approval modal (active view). @@ -602,6 +612,7 @@ mod tests { app_event_tx: tx.clone(), has_input_focus: true, enhanced_keys_supported: false, + live_ring_wrap: false, }); // Start a running task so the status indicator replaces the composer. @@ -652,6 +663,7 @@ mod tests { app_event_tx: tx, has_input_focus: true, enhanced_keys_supported: false, + live_ring_wrap: false, }); // Begin a task: show initial status. @@ -688,6 +700,7 @@ mod tests { app_event_tx: tx, has_input_focus: true, enhanced_keys_supported: false, + live_ring_wrap: false, }); // Activate spinner (status view replaces composer) with no live ring. @@ -740,6 +753,7 @@ mod tests { app_event_tx: tx, has_input_focus: true, enhanced_keys_supported: false, + live_ring_wrap: false, }); pane.set_task_running(true); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 344f025842..31cf10d335 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -49,6 +49,7 @@ use crate::history_cell::CommandOutput; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use crate::live_wrap::RowBuilder; +use crate::markdown; use crate::user_approval_widget::ApprovalRequest; use codex_file_search::FileMatch; use ratatui::style::Stylize; @@ -69,7 +70,6 @@ pub(crate) struct ChatWidget<'a> { total_token_usage: TokenUsage, last_token_usage: TokenUsage, reasoning_buffer: String, - content_buffer: String, // Buffer for streaming assistant answer text; we do not surface partial // We wait for the final AgentMessage event and then emit the full text // at once into scrollback so the history contains a single message. @@ -77,8 +77,11 @@ pub(crate) struct ChatWidget<'a> { running_commands: HashMap, live_builder: RowBuilder, current_stream: Option, + // maximum rows for live overlay; configured via CLI + // maximum preview columns for tool output; None disables fixed width stream_header_emitted: bool, live_max_rows: u16, + preview_max_cols: Option, } struct UserMessage { @@ -117,12 +120,11 @@ impl ChatWidget<'_> { self.submit_op(Op::Interrupt); self.bottom_pane.set_task_running(false); self.bottom_pane.clear_live_ring(); - self.live_builder = RowBuilder::new(self.live_builder.width()); + // Avoid premature hard-wrapping by using a very large target width. + self.live_builder = RowBuilder::new(usize::MAX); self.current_stream = None; - self.stream_header_emitted = false; self.answer_buffer.clear(); self.reasoning_buffer.clear(); - self.content_buffer.clear(); self.request_redraw(); } } @@ -137,30 +139,21 @@ impl ChatWidget<'_> { ]) .areas(area) } - fn emit_stream_header(&mut self, kind: StreamKind) { - use ratatui::text::Line as RLine; - if self.stream_header_emitted { - return; - } - let header = match kind { - StreamKind::Reasoning => RLine::from("thinking".magenta().italic()), - StreamKind::Answer => RLine::from("codex".magenta().bold()), - }; - self.app_event_tx - .send(AppEvent::InsertHistory(vec![header])); - self.stream_header_emitted = true; - } fn finalize_active_stream(&mut self) { if let Some(kind) = self.current_stream { self.finalize_stream(kind); } } + #[allow(clippy::too_many_arguments)] pub(crate) fn new( config: Config, app_event_tx: AppEventSender, initial_prompt: Option, initial_images: Vec, enhanced_keys_supported: bool, + live_rows: u16, + overlay_wrap: bool, + preview_max_cols: Option, ) -> Self { let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); @@ -207,6 +200,7 @@ impl ChatWidget<'_> { app_event_tx, has_input_focus: true, enhanced_keys_supported, + live_ring_wrap: overlay_wrap, }), active_history_cell: None, config, @@ -217,13 +211,15 @@ impl ChatWidget<'_> { total_token_usage: TokenUsage::default(), last_token_usage: TokenUsage::default(), reasoning_buffer: String::new(), - content_buffer: String::new(), answer_buffer: String::new(), running_commands: HashMap::new(), - live_builder: RowBuilder::new(80), + // Avoid pinning to 80 columns. Let the Paragraph in the bottom pane + // handle visual wrapping based on the actual terminal width. + live_builder: RowBuilder::new(usize::MAX), current_stream: None, stream_header_emitted: false, - live_max_rows: 3, + live_max_rows: live_rows, + preview_max_cols, } } @@ -381,10 +377,8 @@ impl ChatWidget<'_> { self.bottom_pane.clear_live_ring(); self.live_builder = RowBuilder::new(self.live_builder.width()); self.current_stream = None; - self.stream_header_emitted = false; self.answer_buffer.clear(); self.reasoning_buffer.clear(); - self.content_buffer.clear(); self.request_redraw(); } EventMsg::PlanUpdate(update) => { @@ -507,7 +501,7 @@ impl ChatWidget<'_> { result, }) => { self.add_to_history(HistoryCell::new_completed_mcp_tool_call( - 80, + self.preview_max_cols.unwrap_or(u16::MAX), invocation, duration, result @@ -654,12 +648,12 @@ impl ChatWidget<'_> { if self.current_stream != Some(kind) { self.current_stream = Some(kind); self.stream_header_emitted = false; - // Clear any previous live content; we're starting a new stream. - self.live_builder = RowBuilder::new(self.live_builder.width()); + // Clear any previous live content; we're starting a new stream. Use a very + // large width so wrapping happens in the renderer, not here. + self.live_builder = RowBuilder::new(usize::MAX); // Ensure the waiting status is visible (composer replaced). self.bottom_pane .update_status_text("waiting for model".to_string()); - self.emit_stream_header(kind); } } @@ -670,7 +664,11 @@ impl ChatWidget<'_> { let drained = self .live_builder .drain_commit_ready(self.live_max_rows as usize); - if !drained.is_empty() { + // Avoid committing partial content to history for both answers and reasoning so that + // the final message can be rendered as a single markdown block. We still update + // the live overlay below so the user sees progress. + let should_commit_now = false; + if should_commit_now && !drained.is_empty() { let mut lines: Vec> = Vec::new(); if !self.stream_header_emitted { match self.current_stream { @@ -709,9 +707,8 @@ impl ChatWidget<'_> { // Flush any partial line as a full row, then drain all remaining rows. self.live_builder.end_line(); let remaining = self.live_builder.drain_rows(); - // TODO: Re-add markdown rendering for assistant answers and reasoning. - // When finalizing, pass the accumulated text through `markdown::append_markdown` - // to build styled `Line<'static>` entries instead of raw plain text lines. + // Re-add markdown rendering for assistant answers and, for reasoning, + // the final remaining chunk. if !remaining.is_empty() || !self.stream_header_emitted { let mut lines: Vec> = Vec::new(); if !self.stream_header_emitted { @@ -725,19 +722,43 @@ impl ChatWidget<'_> { } self.stream_header_emitted = true; } - for r in remaining { - lines.push(ratatui::text::Line::from(r.text)); + + match kind { + StreamKind::Answer => { + // Render the full assistant answer as a single markdown block. + markdown::append_markdown( + self.answer_buffer.as_str(), + &mut lines, + &self.config, + ); + } + StreamKind::Reasoning => { + // Render the full reasoning as a single markdown block. + markdown::append_markdown( + self.reasoning_buffer.as_str(), + &mut lines, + &self.config, + ); + } } + // Close the block with a blank line for readability. lines.push(ratatui::text::Line::from("")); self.app_event_tx.send(AppEvent::InsertHistory(lines)); } // Clear the live overlay and reset state for the next stream. - self.live_builder = RowBuilder::new(self.live_builder.width()); + // Reset the builder with a very large width to delegate wrapping to the renderer. + self.live_builder = RowBuilder::new(usize::MAX); self.bottom_pane.clear_live_ring(); self.current_stream = None; self.stream_header_emitted = false; + + // Clear buffers for the completed stream. + match kind { + StreamKind::Answer => self.answer_buffer.clear(), + StreamKind::Reasoning => self.reasoning_buffer.clear(), + } } } diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 91ee9cfdc7..85e7f6cdbb 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -56,4 +56,13 @@ pub struct Cli { #[clap(skip)] pub config_overrides: CliConfigOverrides, + + /// Maximum columns used for preview/truncation in history cells (e.g., tool output). + /// If set, caps preview width; otherwise previews are uncapped (soft‑wrap, default). + #[arg(long = "max-cols", value_name = "N")] + pub max_cols: Option, + + /// Number of rows to show in the transient live overlay ring above the composer. + #[arg(long = "live-rows", value_name = "N", default_value_t = 3)] + pub live_rows: u16, } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e15a235a71..05e84800ee 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -265,8 +265,26 @@ fn run_ratatui_app( let mut terminal = tui::init(&config)?; terminal.clear()?; + // UI options from CLI + // Default behavior: soft-wrap (no max cols). If --max-cols is set, it overrides. + let overlay_wrap = !cli.max_cols.is_some(); + let preview_max_cols = if let Some(n) = cli.max_cols { + Some(n) + } else { + None + }; + let live_rows = cli.live_rows; + let Cli { prompt, images, .. } = cli; - let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen); + let mut app = App::new( + config.clone(), + prompt, + images, + should_show_trust_screen, + live_rows, + overlay_wrap, + preview_max_cols, + ); // Bridge log receiver into the AppEvent channel so latest log lines update the UI. {