# PR #1685: fix: correctly wrap history items - URL: https://github.com/openai/codex/pull/1685 - Author: nornagon-openai - Created: 2025-07-26 01:06:31 UTC - Updated: 2025-07-28 14:45:56 UTC - Changes: +195/-196, Files changed: 4, Commits: 6 ## Description The overall idea here is: skip ratatui for writing into scrollback, because its primitives are wrong. We want to render full lines of text, that will be wrapped natively by the terminal, and which we never plan to update using ratatui (so the `Buffer` struct is overhead and in fact an inhibition). Instead, we use ANSI scrolling regions (link reference doc to come). Essentially, we: 1. Define a scrolling region that extends from the top of the prompt area all the way to the top of scrollback 2. Scroll that region up by N < (screen_height - viewport_height) lines, in this PR N=1 3. Put our cursor at the top of the newly empty region 4. Print out our new text like normal The terminal interactions here (write_spans and its dependencies) are mostly extracted from ratatui. ## Full Diff ```diff diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ba71596ecd..da3bd50a85 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -463,18 +463,18 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "castaway" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ "rustversion", ] [[package]] name = "cc" -version = "1.2.29" +version = "1.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" +checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" dependencies = [ "jobserver", "libc", @@ -570,9 +570,9 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "clipboard-win" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ "error-code", ] @@ -978,9 +978,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -1527,7 +1527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.0.7", + "rustix 1.0.8", "windows-sys 0.59.0", ] @@ -1976,9 +1976,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "base64 0.22.1", "bytes", @@ -1992,7 +1992,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.0", "system-configuration", "tokio", "tower-service", @@ -2245,9 +2245,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" dependencies = [ "darling", "indoc", @@ -2278,9 +2278,9 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ "bitflags 2.9.1", "cfg-if", @@ -2484,9 +2484,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" +checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" dependencies = [ "bitflags 2.9.1", "libc", @@ -3359,8 +3359,7 @@ dependencies = [ [[package]] name = "ratatui" version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#bca287ddc5d38fe088c79e2eda22422b96226f2e" dependencies = [ "bitflags 2.9.1", "cassowary", @@ -3465,9 +3464,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" dependencies = [ "bitflags 2.9.1", ] @@ -3615,9 +3614,9 @@ dependencies = [ [[package]] name = "rgb" -version = "0.8.51" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a457e416a0f90d246a4c3288bd7a25b2304ca727f253f95be383dd17af56be8f" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" [[package]] name = "ring" @@ -3693,22 +3692,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "rustls" -version = "0.23.28" +version = "0.23.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" +checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" dependencies = [ "once_cell", "rustls-pki-types", @@ -3728,9 +3727,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.3" +version = "0.103.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" dependencies = [ "ring", "rustls-pki-types", @@ -3956,9 +3955,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.141" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" dependencies = [ "indexmap 2.10.0", "itoa", @@ -4151,6 +4150,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -4442,7 +4451,7 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.7", + "rustix 1.0.8", "windows-sys 0.59.0", ] @@ -4463,7 +4472,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" dependencies = [ - "rustix 1.0.7", + "rustix 1.0.8", "windows-sys 0.59.0", ] @@ -4609,7 +4618,7 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2", + "socket2 0.5.10", "tokio-macros", "windows-sys 0.52.0", ] @@ -4751,9 +4760,9 @@ dependencies = [ [[package]] name = "toml_writer" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b679217f2848de74cabd3e8fc5e6d66f40b7da40f8e1954d92054d9010690fd5" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" [[package]] name = "tower" @@ -5575,9 +5584,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" dependencies = [ "memchr", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index eba43e548b..6f89e8faa7 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -40,3 +40,8 @@ strip = "symbols" # See https://github.com/openai/codex/issues/1411 for details. codegen-units = 1 + +[patch.crates-io] +# ratatui = { path = "../../ratatui" } +ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } + diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 9d73e3b386..b88ac8a080 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -35,8 +35,9 @@ lazy_static = "1" mcp-types = { path = "../mcp-types" } path-clean = "1.0.1" ratatui = { version = "0.29.0", features = [ - "unstable-widget-ref", + "scrolling-regions", "unstable-rendered-line-info", + "unstable-widget-ref", ] } ratatui-image = "8.0.0" regex-lite = "0.1" diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 247e024cb0..7948436cd8 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -1,178 +1,162 @@ +use std::io; +use std::io::Write; + use crate::tui; -use ratatui::layout::Rect; -use ratatui::style::Style; +use crossterm::queue; +use crossterm::style::Color as CColor; +use crossterm::style::Colors; +use crossterm::style::Print; +use crossterm::style::SetAttribute; +use crossterm::style::SetBackgroundColor; +use crossterm::style::SetColors; +use crossterm::style::SetForegroundColor; +use ratatui::layout::Position; +use ratatui::prelude::Backend; +use ratatui::style::Color; +use ratatui::style::Modifier; 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() +pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { + let screen_height = terminal + .backend() + .size() + .map(|s| s.height) + .unwrap_or(0xffffu16); + let mut area = terminal.get_frame().area(); + // We scroll up one line at a time because we can't position the cursor + // above the top of the screen. i.e. if + // lines.len() > screen_height - area.top() + // we would need to print the first line above the top of the screen, which + // can't be done. + for line in lines.into_iter() { + // 1. Scroll everything above the viewport up by one line + if area.bottom() >= screen_height { + let top = area.top(); + terminal.backend_mut().scroll_region_up(0..top, 1).ok(); + // 2. Move the cursor to the blank line + terminal.set_cursor_position(Position::new(0, top - 1)).ok(); + } else { + // If the viewport isn't at the bottom of the screen, scroll down instead + terminal + .backend_mut() + .scroll_region_down(area.top()..area.bottom() + 1, 1) + .ok(); + terminal + .set_cursor_position(Position::new(0, area.top())) + .ok(); + area.y += 1; + } + // 3. Write the line + write_spans(&mut std::io::stdout(), line.iter()).ok(); + } + terminal.set_viewport_area(area); } -struct LineBuilder { - term_width: usize, - spans: Vec>, - width: usize, +struct ModifierDiff { + pub from: Modifier, + pub to: Modifier, } -impl LineBuilder { - fn new(term_width: usize) -> Self { - Self { - term_width, - spans: Vec::new(), - width: 0, +impl ModifierDiff { + fn queue(self, mut w: W) -> io::Result<()> + where + W: io::Write, + { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::NoReverse))?; } - } - - fn flush_line(&mut self, out: &mut Vec>) { - 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>) { - 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); + if removed.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; } - } else { - self.push_segment(word.clone(), style); } - word.clear(); - } - - fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec>) { - if ws.is_empty() { - return; + if removed.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::NoItalic))?; } - let space_w = display_width(ws); - if self.width > 0 && self.width + space_w > self.term_width { - self.flush_line(out); + if removed.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::NoUnderline))?; } - if self.width > 0 { - self.push_segment(" ".to_string(), style); + if removed.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; } - ws.clear(); - } -} - -pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { - let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize; - let mut physical: Vec> = Vec::new(); - - for logical in lines.into_iter() { - if logical.spans.is_empty() { - physical.push(logical); - continue; + if removed.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::NotCrossedOut))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::NoBlink))?; } - let mut builder = LineBuilder::new(term_width); - let mut buf_space = String::new(); + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::Reverse))?; + } + if added.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::Bold))?; + } + if added.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::Italic))?; + } + if added.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::Underlined))?; + } + if added.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; + } + if added.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::CrossedOut))?; + } + if added.contains(Modifier::SLOW_BLINK) { + queue!(w, SetAttribute(CAttribute::SlowBlink))?; + } + if added.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::RapidBlink))?; + } - for span in logical.spans.into_iter() { - let style = span.style; - let mut buf_word = String::new(); + Ok(()) + } +} - 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 +fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> +where + I: Iterator>, +{ + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + for span in content { + let mut next_modifier = modifier; + next_modifier.insert(span.style.add_modifier); + next_modifier.remove(span.style.sub_modifier); + if next_modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: next_modifier, + }; + diff.queue(&mut writer)?; + modifier = next_modifier; } - 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::>::new())); + let next_fg = span.style.fg.unwrap_or(Color::Reset); + let next_bg = span.style.bg.unwrap_or(Color::Reset); + if next_fg != fg || next_bg != bg { + queue!( + writer, + SetColors(Colors::new(next_fg.into(), next_bg.into())) + )?; + fg = next_fg; + bg = next_bg; } + + queue!(writer, Print(span.content.clone()))?; } - 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(); + queue!( + writer, + SetForegroundColor(CColor::Reset), + SetBackgroundColor(CColor::Reset), + SetAttribute(crossterm::style::Attribute::Reset), + ) } ``` ## Review Comments ### codex-rs/tui/Cargo.toml - Created: 2025-07-27 16:24:45 UTC | Link: https://github.com/openai/codex/pull/1685#discussion_r2234049268 ```diff @@ -37,6 +37,7 @@ path-clean = "1.0.1" ratatui = { version = "0.29.0", features = [ "unstable-widget-ref", "unstable-rendered-line-info", + "scrolling-regions", ``` > alpha-sort? ### codex-rs/tui/src/insert_history.rs - Created: 2025-07-27 16:28:46 UTC | Link: https://github.com/openai/codex/pull/1685#discussion_r2234050647 ```diff @@ -1,178 +1,158 @@ +use std::io; +use std::io::Write; + use crate::tui; -use ratatui::layout::Rect; -use ratatui::style::Style; +use crossterm::queue; +use crossterm::style::Color as CColor; +use crossterm::style::Colors; +use crossterm::style::Print; +use crossterm::style::SetAttribute; +use crossterm::style::SetBackgroundColor; +use crossterm::style::SetColors; +use crossterm::style::SetForegroundColor; +use ratatui::layout::Position; +use ratatui::prelude::Backend; +use ratatui::style::Color; +use ratatui::style::Modifier; 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>, - width: usize, +struct ModifierDiff { + pub from: Modifier, + pub to: Modifier, } -impl LineBuilder { - fn new(term_width: usize) -> Self { - Self { - term_width, - spans: Vec::new(), - width: 0, +impl ModifierDiff { + fn queue(self, mut w: W) -> io::Result<()> + where + W: io::Write, + { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { ``` > I guess it's tricky enough that it won't help in this case, but FYI, you can use https://crates.io/crates/strum to make an `enum` iterable if you are the one to declare it (like `Modifier` in your fork?): > > ```toml > # Cargo.toml > [dependencies] > strum = "" > strum_macros = "" > ``` > > ```rust > use strum::IntoEnumIterator; // the trait that adds .iter() > #[cfg(feature = "derive")] > use strum::EnumIter; // when using the single-crate derive feature > // OR, if using two crates: > // use strum_macros::EnumIter; > > #[derive(Debug, Clone, Copy, EnumIter)] > enum Color { > Red, > Green, > Blue, > } > > fn main() { > // Enumerate all variants: > for c in Color::iter() { > println!("{c:?}"); > } > > // Collect if you need them as a Vec: > let all: Vec = Color::iter().collect(); > assert_eq!(all.len(), 3); > } > ``` > > It would be nice to have confidence that all variants of `Modifier` are considered, so an option would be to use `iter()` with `match`, I think, but maybe it's too much work here? - Created: 2025-07-27 16:31:45 UTC | Link: https://github.com/openai/codex/pull/1685#discussion_r2234051511 ```diff @@ -1,178 +1,158 @@ +use std::io; +use std::io::Write; + use crate::tui; -use ratatui::layout::Rect; -use ratatui::style::Style; +use crossterm::queue; +use crossterm::style::Color as CColor; +use crossterm::style::Colors; +use crossterm::style::Print; +use crossterm::style::SetAttribute; +use crossterm::style::SetBackgroundColor; +use crossterm::style::SetColors; +use crossterm::style::SetForegroundColor; +use ratatui::layout::Position; +use ratatui::prelude::Backend; +use ratatui::style::Color; +use ratatui::style::Modifier; 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>, - width: usize, +struct ModifierDiff { + pub from: Modifier, + pub to: Modifier, } -impl LineBuilder { - fn new(term_width: usize) -> Self { - Self { - term_width, - spans: Vec::new(), - width: 0, +impl ModifierDiff { + fn queue(self, mut w: W) -> io::Result<()> + where + W: io::Write, + { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::NoReverse))?; } - } - - fn flush_line(&mut self, out: &mut Vec>) { - 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>) { - 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 removed.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; } - if !cur.is_empty() { - self.push_segment(cur, style); - } - } else { - self.push_segment(word.clone(), style); } - word.clear(); - } + if removed.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::NoItalic))?; + } + if removed.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::NoUnderline))?; + } + if removed.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::NotCrossedOut))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::NoBlink))?; + } - fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec>) { - if ws.is_empty() { - return; + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::Reverse))?; } - let space_w = display_width(ws); - if self.width > 0 && self.width + space_w > self.term_width { - self.flush_line(out); + if added.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::Bold))?; } - if self.width > 0 { - self.push_segment(" ".to_string(), style); + if added.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::Italic))?; } - ws.clear(); + if added.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::Underlined))?; + } + if added.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; + } + if added.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::CrossedOut))?; + } + if added.contains(Modifier::SLOW_BLINK) { + queue!(w, SetAttribute(CAttribute::SlowBlink))?; + } + if added.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::RapidBlink))?; + } + + Ok(()) } } -pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { - let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize; - let mut physical: Vec> = Vec::new(); - - for logical in lines.into_iter() { - if logical.spans.is_empty() { - physical.push(logical); - continue; +fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> +where + I: Iterator>, +{ + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + for span in content { + let mut next_modifier = modifier; + next_modifier.insert(span.style.add_modifier); + next_modifier.remove(span.style.sub_modifier); + if next_modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: next_modifier, + }; + diff.queue(&mut writer)?; + modifier = next_modifier; + } + let next_fg = span.style.fg.unwrap_or(Color::Reset); + let next_bg = span.style.bg.unwrap_or(Color::Reset); + if next_fg != fg || next_bg != bg { + queue!( + writer, + SetColors(Colors::new(next_fg.into(), next_bg.into())) + )?; + fg = next_fg; + bg = next_bg; } - let mut builder = LineBuilder::new(term_width); - let mut buf_space = String::new(); + queue!(writer, Print(span.content.clone()))?; + } - for span in logical.spans.into_iter() { - let style = span.style; - let mut buf_word = String::new(); + queue!( + writer, + SetForegroundColor(CColor::Reset), + SetBackgroundColor(CColor::Reset), + SetAttribute(crossterm::style::Attribute::Reset), + ) +} - 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))); +pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { ``` > I would generally favor listing the `pub` elements of the file at the top. - Created: 2025-07-27 16:32:51 UTC | Link: https://github.com/openai/codex/pull/1685#discussion_r2234051793 ```diff @@ -1,178 +1,158 @@ +use std::io; +use std::io::Write; + use crate::tui; -use ratatui::layout::Rect; -use ratatui::style::Style; +use crossterm::queue; +use crossterm::style::Color as CColor; +use crossterm::style::Colors; +use crossterm::style::Print; +use crossterm::style::SetAttribute; +use crossterm::style::SetBackgroundColor; +use crossterm::style::SetColors; +use crossterm::style::SetForegroundColor; +use ratatui::layout::Position; +use ratatui::prelude::Backend; +use ratatui::style::Color; +use ratatui::style::Modifier; 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>, - width: usize, +struct ModifierDiff { + pub from: Modifier, + pub to: Modifier, } -impl LineBuilder { - fn new(term_width: usize) -> Self { - Self { - term_width, - spans: Vec::new(), - width: 0, +impl ModifierDiff { + fn queue(self, mut w: W) -> io::Result<()> + where + W: io::Write, + { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::NoReverse))?; } - } - - fn flush_line(&mut self, out: &mut Vec>) { - 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>) { - 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 removed.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; } - if !cur.is_empty() { - self.push_segment(cur, style); - } - } else { - self.push_segment(word.clone(), style); } - word.clear(); - } + if removed.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::NoItalic))?; + } + if removed.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::NoUnderline))?; + } + if removed.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::NotCrossedOut))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::NoBlink))?; + } - fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec>) { - if ws.is_empty() { - return; + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::Reverse))?; } - let space_w = display_width(ws); - if self.width > 0 && self.width + space_w > self.term_width { - self.flush_line(out); + if added.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::Bold))?; } - if self.width > 0 { - self.push_segment(" ".to_string(), style); + if added.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::Italic))?; } - ws.clear(); + if added.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::Underlined))?; + } + if added.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; + } + if added.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::CrossedOut))?; + } + if added.contains(Modifier::SLOW_BLINK) { + queue!(w, SetAttribute(CAttribute::SlowBlink))?; + } + if added.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::RapidBlink))?; + } + + Ok(()) } } -pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { - let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize; - let mut physical: Vec> = Vec::new(); - - for logical in lines.into_iter() { - if logical.spans.is_empty() { - physical.push(logical); - continue; +fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> +where + I: Iterator>, +{ + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + for span in content { + let mut next_modifier = modifier; + next_modifier.insert(span.style.add_modifier); + next_modifier.remove(span.style.sub_modifier); + if next_modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: next_modifier, + }; + diff.queue(&mut writer)?; + modifier = next_modifier; + } + let next_fg = span.style.fg.unwrap_or(Color::Reset); + let next_bg = span.style.bg.unwrap_or(Color::Reset); + if next_fg != fg || next_bg != bg { + queue!( + writer, + SetColors(Colors::new(next_fg.into(), next_bg.into())) + )?; + fg = next_fg; + bg = next_bg; } - let mut builder = LineBuilder::new(term_width); - let mut buf_space = String::new(); + queue!(writer, Print(span.content.clone()))?; + } - for span in logical.spans.into_iter() { - let style = span.style; - let mut buf_word = String::new(); + queue!( + writer, + SetForegroundColor(CColor::Reset), + SetBackgroundColor(CColor::Reset), + SetAttribute(crossterm::style::Attribute::Reset), + ) +} - 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))); +pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { + let screen_height = terminal + .backend_mut() + .size() + .map(|s| s.height) + .unwrap_or(0xffffu16); ``` > So "max height" in the `unwrap_or()` case? - Created: 2025-07-27 16:33:04 UTC | Link: https://github.com/openai/codex/pull/1685#discussion_r2234051876 ```diff @@ -1,178 +1,158 @@ +use std::io; +use std::io::Write; + use crate::tui; -use ratatui::layout::Rect; -use ratatui::style::Style; +use crossterm::queue; +use crossterm::style::Color as CColor; +use crossterm::style::Colors; +use crossterm::style::Print; +use crossterm::style::SetAttribute; +use crossterm::style::SetBackgroundColor; +use crossterm::style::SetColors; +use crossterm::style::SetForegroundColor; +use ratatui::layout::Position; +use ratatui::prelude::Backend; +use ratatui::style::Color; +use ratatui::style::Modifier; 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>, - width: usize, +struct ModifierDiff { + pub from: Modifier, + pub to: Modifier, } -impl LineBuilder { - fn new(term_width: usize) -> Self { - Self { - term_width, - spans: Vec::new(), - width: 0, +impl ModifierDiff { + fn queue(self, mut w: W) -> io::Result<()> + where + W: io::Write, + { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::NoReverse))?; } - } - - fn flush_line(&mut self, out: &mut Vec>) { - 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>) { - 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 removed.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; } - if !cur.is_empty() { - self.push_segment(cur, style); - } - } else { - self.push_segment(word.clone(), style); } - word.clear(); - } + if removed.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::NoItalic))?; + } + if removed.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::NoUnderline))?; + } + if removed.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::NotCrossedOut))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::NoBlink))?; + } - fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec>) { - if ws.is_empty() { - return; + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::Reverse))?; } - let space_w = display_width(ws); - if self.width > 0 && self.width + space_w > self.term_width { - self.flush_line(out); + if added.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::Bold))?; } - if self.width > 0 { - self.push_segment(" ".to_string(), style); + if added.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::Italic))?; } - ws.clear(); + if added.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::Underlined))?; + } + if added.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; + } + if added.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::CrossedOut))?; + } + if added.contains(Modifier::SLOW_BLINK) { + queue!(w, SetAttribute(CAttribute::SlowBlink))?; + } + if added.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::RapidBlink))?; + } + + Ok(()) } } -pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { - let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize; - let mut physical: Vec> = Vec::new(); - - for logical in lines.into_iter() { - if logical.spans.is_empty() { - physical.push(logical); - continue; +fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> +where + I: Iterator>, +{ + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + for span in content { + let mut next_modifier = modifier; + next_modifier.insert(span.style.add_modifier); + next_modifier.remove(span.style.sub_modifier); + if next_modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: next_modifier, + }; + diff.queue(&mut writer)?; + modifier = next_modifier; + } + let next_fg = span.style.fg.unwrap_or(Color::Reset); + let next_bg = span.style.bg.unwrap_or(Color::Reset); + if next_fg != fg || next_bg != bg { + queue!( + writer, + SetColors(Colors::new(next_fg.into(), next_bg.into())) + )?; + fg = next_fg; + bg = next_bg; } - let mut builder = LineBuilder::new(term_width); - let mut buf_space = String::new(); + queue!(writer, Print(span.content.clone()))?; + } - for span in logical.spans.into_iter() { - let style = span.style; - let mut buf_word = String::new(); + queue!( + writer, + SetForegroundColor(CColor::Reset), + SetBackgroundColor(CColor::Reset), + SetAttribute(crossterm::style::Attribute::Reset), + ) +} - 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))); +pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { + let screen_height = terminal + .backend_mut() + .size() + .map(|s| s.height) + .unwrap_or(0xffffu16); + let mut area = terminal.get_frame().area(); + // We scroll up one line at a time because ``` > unfini