diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cc3118e860..5de761812a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -59,6 +59,9 @@ pub(crate) struct App<'a> { pending_history_lines: Vec>, + /// Pending replacement for the most recently inserted history line. + pending_history_update: Option>, + /// Stored parameters needed to instantiate the ChatWidget later, e.g., /// after dismissing the Git-repo warning. chat_args: Option, @@ -164,6 +167,7 @@ impl App<'_> { Self { app_event_tx, pending_history_lines: Vec::new(), + pending_history_update: None, app_event_rx, app_state, config, @@ -213,6 +217,10 @@ impl App<'_> { self.pending_history_lines.extend(lines); self.app_event_tx.send(AppEvent::RequestRedraw); } + AppEvent::UpdateHistoryLastLine(line) => { + self.pending_history_update = Some(line); + self.app_event_tx.send(AppEvent::RequestRedraw); + } AppEvent::RequestRedraw => { self.schedule_redraw(); } @@ -431,6 +439,9 @@ impl App<'_> { ); self.pending_history_lines.clear(); } + if let Some(line) = self.pending_history_update.take() { + crate::insert_history::overwrite_last_history_line(terminal, line); + } match &mut self.app_state { AppState::Chat { widget } => { terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 77a600d304..9656d81293 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -48,4 +48,7 @@ pub(crate) enum AppEvent { }, InsertHistory(Vec>), + /// Replace the most recently inserted history line without adding a new + /// newline. Used for streaming updates of the agent's response. + UpdateHistoryLastLine(Line<'static>), } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4c415777a9..bdce4d7589 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -30,6 +30,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; +use ratatui::text::Line; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::unbounded_channel; @@ -45,6 +46,8 @@ use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use crate::user_approval_widget::ApprovalRequest; use codex_file_search::FileMatch; +use crate::markdown::append_markdown; +use ratatui::style::Stylize; struct RunningCommand { command: Vec, @@ -59,10 +62,15 @@ pub(crate) struct ChatWidget<'a> { config: Config, initial_user_message: Option, token_usage: TokenUsage, + /// Buffer for streaming assistant reasoning text. + /// + /// `reasoning_streamed_lines` tracks lines already emitted for streaming. + reasoning_streamed_lines: Vec>, reasoning_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. + /// Buffer for streaming assistant answer text. + /// + /// `answer_streamed_lines` tracks lines already emitted for streaming. + answer_streamed_lines: Vec>, answer_buffer: String, running_commands: HashMap, } @@ -149,7 +157,9 @@ impl ChatWidget<'_> { initial_images, ), token_usage: TokenUsage::default(), + reasoning_streamed_lines: Vec::new(), reasoning_buffer: String::new(), + answer_streamed_lines: Vec::new(), answer_buffer: String::new(), running_commands: HashMap::new(), } @@ -237,45 +247,109 @@ impl ChatWidget<'_> { } EventMsg::AgentMessage(AgentMessageEvent { message }) => { // Final assistant answer. Prefer the fully provided message - // from the event; if it is empty fall back to any accumulated - // delta buffer (some providers may only stream deltas and send - // an empty final message). let full = if message.is_empty() { std::mem::take(&mut self.answer_buffer) } else { - self.answer_buffer.clear(); + self.answer_buffer = message.clone(); message }; - if !full.is_empty() { - self.add_to_history(HistoryCell::new_agent_message(&self.config, full)); + if self.answer_streamed_lines.is_empty() { + if !full.is_empty() { + self.add_to_history(HistoryCell::new_agent_message(&self.config, full)); + } + } else { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from("codex".magenta().bold())); + append_markdown(&full, &mut lines, &self.config); + let old_len = self.answer_streamed_lines.len(); + if lines.len() > old_len { + let to_insert = lines[old_len..].to_vec(); + self.app_event_tx.send(AppEvent::InsertHistory(to_insert)); + } else if let Some(last) = lines.last() { + self.app_event_tx + .send(AppEvent::UpdateHistoryLastLine(last.clone())); + } + self.app_event_tx + .send(AppEvent::InsertHistory(vec![Line::from("")])) + ; + self.answer_streamed_lines.clear(); } + self.answer_buffer.clear(); self.request_redraw(); } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { - // Buffer only – do not emit partial lines. This avoids cases - // where long responses appear truncated if the terminal - // wrapped early. The full message is emitted on - // AgentMessage. self.answer_buffer.push_str(&delta); + let mut lines: Vec> = Vec::new(); + if self.answer_streamed_lines.is_empty() { + lines.push(Line::from("codex".magenta().bold())); + } + append_markdown(&self.answer_buffer, &mut lines, &self.config); + if self.answer_streamed_lines.is_empty() { + self.app_event_tx.send(AppEvent::InsertHistory(lines.clone())); + } else { + if lines.len() > self.answer_streamed_lines.len() { + let new_lines = lines[self.answer_streamed_lines.len()..].to_vec(); + self.app_event_tx.send(AppEvent::InsertHistory(new_lines)); + } else if let Some(last) = lines.last() { + self.app_event_tx + .send(AppEvent::UpdateHistoryLastLine(last.clone())); + } + } + self.answer_streamed_lines = lines; } EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { - // Buffer only – disable incremental reasoning streaming so we - // avoid truncated intermediate lines. Full text emitted on - // AgentReasoning. self.reasoning_buffer.push_str(&delta); + let mut lines: Vec> = Vec::new(); + if self.reasoning_streamed_lines.is_empty() { + lines.push(Line::from("thinking".magenta().italic())); + } + append_markdown(&self.reasoning_buffer, &mut lines, &self.config); + if self.reasoning_streamed_lines.is_empty() { + self.app_event_tx.send(AppEvent::InsertHistory(lines.clone())); + } else { + if lines.len() > self.reasoning_streamed_lines.len() { + let new_lines = + lines[self.reasoning_streamed_lines.len()..].to_vec(); + self.app_event_tx.send(AppEvent::InsertHistory(new_lines)); + } else if let Some(last) = lines.last() { + self.app_event_tx + .send(AppEvent::UpdateHistoryLastLine(last.clone())); + } + } + self.reasoning_streamed_lines = lines; } EventMsg::AgentReasoning(AgentReasoningEvent { text }) => { - // Emit full reasoning text once. Some providers might send - // final event with empty text if only deltas were used. let full = if text.is_empty() { std::mem::take(&mut self.reasoning_buffer) } else { - self.reasoning_buffer.clear(); + self.reasoning_buffer = text.clone(); text }; - if !full.is_empty() { - self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full)); + if self.reasoning_streamed_lines.is_empty() { + if !full.is_empty() { + self.add_to_history(HistoryCell::new_agent_reasoning( + &self.config, + full, + )); + } + } else { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from("thinking".magenta().italic())); + append_markdown(&full, &mut lines, &self.config); + let old_len = self.reasoning_streamed_lines.len(); + if lines.len() > old_len { + let to_insert = lines[old_len..].to_vec(); + self.app_event_tx.send(AppEvent::InsertHistory(to_insert)); + } else if let Some(last) = lines.last() { + self.app_event_tx + .send(AppEvent::UpdateHistoryLastLine(last.clone())); + } + self.app_event_tx + .send(AppEvent::InsertHistory(vec![Line::from("")])) + ; + self.reasoning_streamed_lines.clear(); } + self.reasoning_buffer.clear(); self.request_redraw(); } EventMsg::TaskStarted => { diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index efd08a71c2..9c22dc774f 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -13,6 +13,7 @@ use crossterm::style::SetAttribute; use crossterm::style::SetBackgroundColor; use crossterm::style::SetColors; use crossterm::style::SetForegroundColor; +use crossterm::terminal::{Clear, ClearType}; use ratatui::layout::Size; use ratatui::prelude::Backend; use ratatui::style::Color; @@ -79,6 +80,31 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { } } +/// Overwrite the most recently inserted history line with `line` without +/// inserting a newline. Used for streaming updates where the last line is +/// still being written. +pub(crate) fn overwrite_last_history_line(terminal: &mut tui::Tui, line: Line) { + let cursor_pos = terminal.get_cursor_position().ok(); + let mut area = terminal.get_frame().area(); + + // Limit the scroll region to everything above the viewport so that + // rewriting the last line does not affect the bottom pane. + queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok(); + + // Move to the start of the last history line, clear it, and write the new content. + let y = area.top().saturating_sub(1); + queue!(std::io::stdout(), MoveTo(0, y)).ok(); + queue!(std::io::stdout(), Clear(ClearType::CurrentLine)).ok(); + write_spans(&mut std::io::stdout(), line.iter()).ok(); + + queue!(std::io::stdout(), ResetScrollRegion).ok(); + + // Restore cursor position. + if let Some(cursor_pos) = cursor_pos { + queue!(std::io::stdout(), MoveTo(cursor_pos.x, cursor_pos.y)).ok(); + } +} + fn wrapped_line_count(lines: &[Line], width: u16) -> u16 { let mut count = 0; for line in lines {