Files
llmx/codex-rs/tui/src/live_wrap.rs
easong-openai 906d449760 Stream model responses (#1810)
Stream models thoughts and responses instead of waiting for the whole
thing to come through. Very rough right now, but I'm making the risk call to push through.
2025-08-05 04:23:22 +00:00

291 lines
9.4 KiB
Rust

use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
/// A single visual row produced by RowBuilder.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
pub text: String,
/// True if this row ends with an explicit line break (as opposed to a hard wrap).
pub explicit_break: bool,
}
impl Row {
pub fn width(&self) -> usize {
self.text.width()
}
}
/// Incrementally wraps input text into visual rows of at most `width` cells.
///
/// Step 1: plain-text only. ANSI-carry and styled spans will be added later.
pub struct RowBuilder {
target_width: usize,
/// Buffer for the current logical line (until a '\n' is seen).
current_line: String,
/// Output rows built so far for the current logical line and previous ones.
rows: Vec<Row>,
}
impl RowBuilder {
pub fn new(target_width: usize) -> Self {
Self {
target_width: target_width.max(1),
current_line: String::new(),
rows: Vec::new(),
}
}
pub fn width(&self) -> usize {
self.target_width
}
pub fn set_width(&mut self, width: usize) {
self.target_width = width.max(1);
// Rewrap everything we have (simple approach for Step 1).
let mut all = String::new();
for row in self.rows.drain(..) {
all.push_str(&row.text);
if row.explicit_break {
all.push('\n');
}
}
all.push_str(&self.current_line);
self.current_line.clear();
self.push_fragment(&all);
}
/// Push an input fragment. May contain newlines.
pub fn push_fragment(&mut self, fragment: &str) {
if fragment.is_empty() {
return;
}
let mut start = 0usize;
for (i, ch) in fragment.char_indices() {
if ch == '\n' {
// Flush anything pending before the newline.
if start < i {
self.current_line.push_str(&fragment[start..i]);
}
self.flush_current_line(true);
start = i + ch.len_utf8();
}
}
if start < fragment.len() {
self.current_line.push_str(&fragment[start..]);
self.wrap_current_line();
}
}
/// Mark the end of the current logical line (equivalent to pushing a '\n').
pub fn end_line(&mut self) {
self.flush_current_line(true);
}
/// Drain and return all produced rows.
pub fn drain_rows(&mut self) -> Vec<Row> {
std::mem::take(&mut self.rows)
}
/// Return a snapshot of produced rows (non-draining).
pub fn rows(&self) -> &[Row] {
&self.rows
}
/// Rows suitable for display, including the current partial line if any.
pub fn display_rows(&self) -> Vec<Row> {
let mut out = self.rows.clone();
if !self.current_line.is_empty() {
out.push(Row {
text: self.current_line.clone(),
explicit_break: false,
});
}
out
}
/// Drain the oldest rows that exceed `max_keep` display rows (including the
/// current partial line, if any). Returns the drained rows in order.
pub fn drain_commit_ready(&mut self, max_keep: usize) -> Vec<Row> {
let display_count = self.rows.len() + if self.current_line.is_empty() { 0 } else { 1 };
if display_count <= max_keep {
return Vec::new();
}
let to_commit = display_count - max_keep;
let commit_count = to_commit.min(self.rows.len());
let mut drained = Vec::with_capacity(commit_count);
for _ in 0..commit_count {
drained.push(self.rows.remove(0));
}
drained
}
fn flush_current_line(&mut self, explicit_break: bool) {
// Wrap any remaining content in the current line and then finalize with explicit_break.
self.wrap_current_line();
// If the current line ended exactly on a width boundary and is non-empty, represent
// the explicit break as an empty explicit row so that fragmentation invariance holds.
if explicit_break {
if self.current_line.is_empty() {
// We ended on a boundary previously; add an empty explicit row.
self.rows.push(Row {
text: String::new(),
explicit_break: true,
});
} else {
// There is leftover content that did not wrap yet; push it now with the explicit flag.
let mut s = String::new();
std::mem::swap(&mut s, &mut self.current_line);
self.rows.push(Row {
text: s,
explicit_break: true,
});
}
}
// Reset current line buffer for next logical line.
self.current_line.clear();
}
fn wrap_current_line(&mut self) {
// While the current_line exceeds width, cut a prefix.
loop {
if self.current_line.is_empty() {
break;
}
let (prefix, suffix, taken) =
take_prefix_by_width(&self.current_line, self.target_width);
if taken == 0 {
// Avoid infinite loop on pathological inputs; take one scalar and continue.
if let Some((i, ch)) = self.current_line.char_indices().next() {
let len = i + ch.len_utf8();
let p = self.current_line[..len].to_string();
self.rows.push(Row {
text: p,
explicit_break: false,
});
self.current_line = self.current_line[len..].to_string();
continue;
}
break;
}
if suffix.is_empty() {
// Fits entirely; keep in buffer (do not push yet) so we can append more later.
break;
} else {
// Emit wrapped prefix as a non-explicit row and continue with the remainder.
self.rows.push(Row {
text: prefix,
explicit_break: false,
});
self.current_line = suffix.to_string();
}
}
}
}
/// Take a prefix of `text` whose visible width is at most `max_cols`.
/// Returns (prefix, suffix, prefix_width).
pub fn take_prefix_by_width(text: &str, max_cols: usize) -> (String, &str, usize) {
if max_cols == 0 || text.is_empty() {
return (String::new(), text, 0);
}
let mut cols = 0usize;
let mut end_idx = 0usize;
for (i, ch) in text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if cols.saturating_add(ch_width) > max_cols {
break;
}
cols += ch_width;
end_idx = i + ch.len_utf8();
if cols == max_cols {
break;
}
}
let prefix = text[..end_idx].to_string();
let suffix = &text[end_idx..];
(prefix, suffix, cols)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn rows_do_not_exceed_width_ascii() {
let mut rb = RowBuilder::new(10);
rb.push_fragment("hello whirl this is a test");
let rows = rb.rows().to_vec();
assert_eq!(
rows,
vec![
Row {
text: "hello whir".to_string(),
explicit_break: false
},
Row {
text: "l this is ".to_string(),
explicit_break: false
}
]
);
}
#[test]
fn rows_do_not_exceed_width_emoji_cjk() {
// 😀 is width 2; 你/好 are width 2.
let mut rb = RowBuilder::new(6);
rb.push_fragment("😀😀 你好");
let rows = rb.rows().to_vec();
// At width 6, we expect the first row to fit exactly two emojis and a space
// (2 + 2 + 1 = 5) plus one more column for the first CJK char (2 would overflow),
// so only the two emojis and the space fit; the rest remains buffered.
assert_eq!(
rows,
vec![Row {
text: "😀😀 ".to_string(),
explicit_break: false
}]
);
}
#[test]
fn fragmentation_invariance_long_token() {
let s = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; // 26 chars
let mut rb_all = RowBuilder::new(7);
rb_all.push_fragment(s);
let all_rows = rb_all.rows().to_vec();
let mut rb_chunks = RowBuilder::new(7);
for i in (0..s.len()).step_by(3) {
let end = (i + 3).min(s.len());
rb_chunks.push_fragment(&s[i..end]);
}
let chunk_rows = rb_chunks.rows().to_vec();
assert_eq!(all_rows, chunk_rows);
}
#[test]
fn newline_splits_rows() {
let mut rb = RowBuilder::new(10);
rb.push_fragment("hello\nworld");
let rows = rb.display_rows();
assert!(rows.iter().any(|r| r.explicit_break));
assert_eq!(rows[0].text, "hello");
// Second row should begin with 'world'
assert!(rows.iter().any(|r| r.text.starts_with("world")));
}
#[test]
fn rewrap_on_width_change() {
let mut rb = RowBuilder::new(10);
rb.push_fragment("abcdefghijK");
assert!(!rb.rows().is_empty());
rb.set_width(5);
for r in rb.rows() {
assert!(r.width() <= 5);
}
}
}