Compare commits

...

1 Commits

Author SHA1 Message Date
aibrahim-oai
1c7287be17 feat(tui): stream agent message deltas 2025-08-01 20:16:51 -07:00
4 changed files with 125 additions and 37 deletions

View File

@@ -5,6 +5,8 @@ use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff;
use crate::git_warning_screen::GitWarningOutcome;
use crate::git_warning_screen::GitWarningScreen;
use crate::insert_history::insert_history_lines;
use crate::insert_history::wrapped_line_count;
use crate::slash_command::SlashCommand;
use crate::tui;
use codex_core::config::Config;
@@ -18,8 +20,10 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::layout::Offset;
use ratatui::layout::Rect;
use ratatui::prelude::Backend;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
@@ -60,6 +64,12 @@ pub(crate) struct App<'a> {
pending_history_lines: Vec<Line<'static>>,
/// All history that has been committed to scrollback.
history_lines: Vec<Line<'static>>,
/// Currently streaming history cell if any.
live_history_lines: Option<Vec<Line<'static>>>,
/// Stored parameters needed to instantiate the ChatWidget later, e.g.,
/// after dismissing the Git-repo warning.
chat_args: Option<ChatWidgetArgs>,
@@ -165,6 +175,8 @@ impl App<'_> {
Self {
app_event_tx,
pending_history_lines: Vec::new(),
history_lines: Vec::new(),
live_history_lines: None,
app_event_rx,
app_state,
config,
@@ -211,9 +223,18 @@ impl App<'_> {
while let Ok(event) = self.app_event_rx.recv() {
match event {
AppEvent::InsertHistory(lines) => {
self.history_lines.extend(lines.clone());
self.pending_history_lines.extend(lines);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::SetLiveHistory(lines) => {
self.live_history_lines = Some(lines);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::ClearLiveHistory => {
self.live_history_lines = None;
self.app_event_tx.send(AppEvent::RequestRedraw);
}
AppEvent::RequestRedraw => {
self.schedule_redraw();
}
@@ -289,6 +310,9 @@ impl App<'_> {
},
AppEvent::DispatchCommand(command) => match command {
SlashCommand::New => {
self.history_lines.clear();
self.pending_history_lines.clear();
self.live_history_lines = None;
let new_widget = Box::new(ChatWidget::new(
self.config.clone(),
self.app_event_tx.clone(),
@@ -413,37 +437,92 @@ impl App<'_> {
}
let size = terminal.size()?;
let desired_height = match &self.app_state {
AppState::Chat { widget } => widget.desired_height(size.width),
let screen_height = size.height;
let width = size.width;
let bottom_height = match &self.app_state {
AppState::Chat { widget } => widget.desired_height(width),
AppState::GitWarning { .. } => 10,
};
let live_height = self
.live_history_lines
.as_ref()
.map(|lines| wrapped_line_count(lines, width))
.unwrap_or(0);
let total_height = bottom_height.saturating_add(live_height);
let mut area = terminal.viewport_area;
area.height = desired_height.min(size.height);
area.width = size.width;
if area.bottom() > size.height {
terminal
.backend_mut()
.scroll_region_up(0..area.top(), area.bottom() - size.height)?;
area.y = size.height - area.height;
}
if area != terminal.viewport_area {
if total_height <= screen_height {
let mut area = terminal.viewport_area;
area.height = total_height.min(screen_height);
area.width = width;
if area.bottom() > screen_height {
terminal
.backend_mut()
.scroll_region_up(0..area.top(), area.bottom() - screen_height)?;
area.y = screen_height - area.height;
}
if area != terminal.viewport_area {
terminal.clear()?;
terminal.set_viewport_area(area);
}
if !self.pending_history_lines.is_empty() {
insert_history_lines(terminal, self.pending_history_lines.clone());
self.pending_history_lines.clear();
}
match &mut self.app_state {
AppState::Chat { widget } => {
let live_lines = self.live_history_lines.clone();
terminal.draw(|frame| {
let area = frame.area();
if let Some(lines) = &live_lines {
let live_h = wrapped_line_count(lines, area.width);
let live_area = Rect {
x: area.x,
y: area.y,
width: area.width,
height: live_h,
};
let bottom_area = Rect {
x: area.x,
y: area.y + live_h,
width: area.width,
height: area.height - live_h,
};
frame.render_widget(Paragraph::new(lines.clone()), live_area);
frame.render_widget_ref(&**widget, bottom_area);
} else {
frame.render_widget_ref(&**widget, area);
}
})?;
}
AppState::GitWarning { screen } => {
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
}
}
} else {
let bottom_height = bottom_height.min(screen_height);
let area = Rect {
x: 0,
y: screen_height - bottom_height,
width,
height: bottom_height,
};
terminal.clear()?;
terminal.set_viewport_area(area);
}
if !self.pending_history_lines.is_empty() {
crate::insert_history::insert_history_lines(
terminal,
self.pending_history_lines.clone(),
);
self.pending_history_lines.clear();
}
match &mut self.app_state {
AppState::Chat { widget } => {
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
let mut all_lines = self.history_lines.clone();
if let Some(live) = &self.live_history_lines {
all_lines.extend(live.clone());
}
AppState::GitWarning { screen } => {
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
insert_history_lines(terminal, all_lines);
self.pending_history_lines.clear();
match &mut self.app_state {
AppState::Chat { widget } => {
terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?;
}
AppState::GitWarning { screen } => {
terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?;
}
}
}
Ok(())

View File

@@ -48,4 +48,10 @@ pub(crate) enum AppEvent {
},
InsertHistory(Vec<Line<'static>>),
/// Update the currently streaming history cell.
SetLiveHistory(Vec<Line<'static>>),
/// Clear any currently streaming history cell.
ClearLiveHistory,
}

View File

@@ -60,9 +60,8 @@ pub(crate) struct ChatWidget<'a> {
initial_user_message: Option<UserMessage>,
token_usage: TokenUsage,
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; partial content is rendered
// in a live history cell until the final message is committed.
answer_buffer: String,
running_commands: HashMap<String, RunningCommand>,
}
@@ -249,20 +248,23 @@ impl ChatWidget<'_> {
if !full.is_empty() {
self.add_to_history(HistoryCell::new_agent_message(&self.config, full));
}
self.app_event_tx.send(AppEvent::ClearLiveHistory);
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 cell = HistoryCell::new_agent_message(&self.config, self.answer_buffer.clone());
self.app_event_tx
.send(AppEvent::SetLiveHistory(cell.plain_lines()));
self.request_redraw();
}
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 cell =
HistoryCell::new_agent_reasoning(&self.config, self.reasoning_buffer.clone());
self.app_event_tx
.send(AppEvent::SetLiveHistory(cell.plain_lines()));
self.request_redraw();
}
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
// Emit full reasoning text once. Some providers might send
@@ -276,6 +278,7 @@ impl ChatWidget<'_> {
if !full.is_empty() {
self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full));
}
self.app_event_tx.send(AppEvent::ClearLiveHistory);
self.request_redraw();
}
EventMsg::TaskStarted => {

View File

@@ -79,7 +79,7 @@ pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line>) {
}
}
fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
pub(crate) fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
let mut count = 0;
for line in lines {
count += line_height(line, width);
@@ -87,7 +87,7 @@ fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
count
}
fn line_height(line: &Line, width: u16) -> u16 {
pub(crate) fn line_height(line: &Line, width: u16) -> u16 {
use unicode_width::UnicodeWidthStr;
// get the total display width of the line, accounting for double-width chars
let total_width = line