mirror of
https://github.com/openai/codex.git
synced 2026-11-02 08:19:52 +00:00
This update replaces the previous ratatui history widget with an append-only log so that the terminal can handle text selection and scrolling. It also disables streaming responses, which we'll do our best to bring back in a later PR. It also adds a small summary of token use after the TUI exits.
179 lines
6.2 KiB
Rust
179 lines
6.2 KiB
Rust
use crate::tui;
|
||
use ratatui::layout::Rect;
|
||
use ratatui::style::Style;
|
||
use ratatui::text::Line;
|
||
use ratatui::text::Span;
|
||
use ratatui::widgets::Paragraph;
|
||
use ratatui::widgets::Widget;
|
||
use unicode_width::UnicodeWidthChar;
|
||
|
||
/// Insert a batch of history lines into the terminal scrollback above the
|
||
/// inline viewport.
|
||
///
|
||
/// The incoming `lines` are the logical lines supplied by the
|
||
/// `ConversationHistory`. They may contain embedded newlines and arbitrary
|
||
/// runs of whitespace inside individual [`Span`]s. All of that must be
|
||
/// normalised before writing to the backing terminal buffer because the
|
||
/// ratatui [`Paragraph`] widget does not perform soft‑wrapping when used in
|
||
/// conjunction with [`Terminal::insert_before`].
|
||
///
|
||
/// This function performs a minimal wrapping / normalisation pass:
|
||
///
|
||
/// * A terminal width is determined via `Terminal::size()` (falling back to
|
||
/// 80 columns if the size probe fails).
|
||
/// * Each logical line is broken into words and whitespace. Consecutive
|
||
/// whitespace is collapsed to a single space; leading whitespace is
|
||
/// discarded.
|
||
/// * Words that do not fit on the current line cause a soft wrap. Extremely
|
||
/// long words (longer than the terminal width) are split character by
|
||
/// character so they still populate the display instead of overflowing the
|
||
/// line.
|
||
/// * Explicit `\n` characters inside a span force a hard line break.
|
||
/// * Empty lines (including a trailing newline at the end of the batch) are
|
||
/// preserved so vertical spacing remains faithful to the logical history.
|
||
///
|
||
/// Finally the physical lines are rendered directly into the terminal's
|
||
/// scrollback region using [`Terminal::insert_before`]. Any backend error is
|
||
/// ignored: failing to insert history is non‑fatal and a subsequent redraw
|
||
/// will eventually repaint a consistent view.
|
||
fn display_width(s: &str) -> usize {
|
||
s.chars()
|
||
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
|
||
.sum()
|
||
}
|
||
|
||
struct LineBuilder {
|
||
term_width: usize,
|
||
spans: Vec<Span<'static>>,
|
||
width: usize,
|
||
}
|
||
|
||
impl LineBuilder {
|
||
fn new(term_width: usize) -> Self {
|
||
Self {
|
||
term_width,
|
||
spans: Vec::new(),
|
||
width: 0,
|
||
}
|
||
}
|
||
|
||
fn flush_line(&mut self, out: &mut Vec<Line<'static>>) {
|
||
out.push(Line::from(std::mem::take(&mut self.spans)));
|
||
self.width = 0;
|
||
}
|
||
|
||
fn push_segment(&mut self, text: String, style: Style) {
|
||
self.width += display_width(&text);
|
||
self.spans.push(Span::styled(text, style));
|
||
}
|
||
|
||
fn push_word(&mut self, word: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
|
||
if word.is_empty() {
|
||
return;
|
||
}
|
||
let w_len = display_width(word);
|
||
if self.width > 0 && self.width + w_len > self.term_width {
|
||
self.flush_line(out);
|
||
}
|
||
if w_len > self.term_width && self.width == 0 {
|
||
// Split an overlong word across multiple lines.
|
||
let mut cur = String::new();
|
||
let mut cur_w = 0;
|
||
for ch in word.chars() {
|
||
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||
if cur_w + ch_w > self.term_width && cur_w > 0 {
|
||
self.push_segment(cur.clone(), style);
|
||
self.flush_line(out);
|
||
cur.clear();
|
||
cur_w = 0;
|
||
}
|
||
cur.push(ch);
|
||
cur_w += ch_w;
|
||
}
|
||
if !cur.is_empty() {
|
||
self.push_segment(cur, style);
|
||
}
|
||
} else {
|
||
self.push_segment(word.clone(), style);
|
||
}
|
||
word.clear();
|
||
}
|
||
|
||
fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
|
||
if ws.is_empty() {
|
||
return;
|
||
}
|
||
let space_w = display_width(ws);
|
||
if self.width > 0 && self.width + space_w > self.term_width {
|
||
self.flush_line(out);
|
||
}
|
||
if self.width > 0 {
|
||
self.push_segment(" ".to_string(), style);
|
||
}
|
||
ws.clear();
|
||
}
|
||
}
|
||
|
||
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {
|
||
let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize;
|
||
let mut physical: Vec<Line<'static>> = Vec::new();
|
||
|
||
for logical in lines.into_iter() {
|
||
if logical.spans.is_empty() {
|
||
physical.push(logical);
|
||
continue;
|
||
}
|
||
|
||
let mut builder = LineBuilder::new(term_width);
|
||
let mut buf_space = String::new();
|
||
|
||
for span in logical.spans.into_iter() {
|
||
let style = span.style;
|
||
let mut buf_word = String::new();
|
||
|
||
for ch in span.content.chars() {
|
||
if ch == '\n' {
|
||
builder.push_word(&mut buf_word, style, &mut physical);
|
||
buf_space.clear();
|
||
builder.flush_line(&mut physical);
|
||
continue;
|
||
}
|
||
if ch.is_whitespace() {
|
||
builder.push_word(&mut buf_word, style, &mut physical);
|
||
buf_space.push(ch);
|
||
} else {
|
||
builder.consume_whitespace(&mut buf_space, style, &mut physical);
|
||
buf_word.push(ch);
|
||
}
|
||
if builder.width >= term_width {
|
||
builder.flush_line(&mut physical);
|
||
}
|
||
}
|
||
builder.push_word(&mut buf_word, style, &mut physical);
|
||
// whitespace intentionally left to allow collapsing across spans
|
||
}
|
||
if !builder.spans.is_empty() {
|
||
physical.push(Line::from(std::mem::take(&mut builder.spans)));
|
||
} else {
|
||
// Preserve explicit blank line (e.g. due to a trailing newline).
|
||
physical.push(Line::from(Vec::<Span<'static>>::new()));
|
||
}
|
||
}
|
||
|
||
let total = physical.len() as u16;
|
||
terminal
|
||
.insert_before(total, |buf| {
|
||
let width = buf.area.width;
|
||
for (i, line) in physical.into_iter().enumerate() {
|
||
let area = Rect {
|
||
x: 0,
|
||
y: i as u16,
|
||
width,
|
||
height: 1,
|
||
};
|
||
Paragraph::new(line).render(area, buf);
|
||
}
|
||
})
|
||
.ok();
|
||
}
|