diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ba71596e..da3bd50a 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 eba43e54..6f89e8fa 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 9d73e3b3..b88ac8a0 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 247e024c..7948436c 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() -} - -struct LineBuilder { - term_width: usize, - spans: Vec>, - 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>) { - 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); - } - } 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; - } - 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>) { - 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; - } - - 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))); + 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 { - // Preserve explicit blank line (e.g. due to a trailing newline). - physical.push(Line::from(Vec::>::new())); + // 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 ModifierDiff { + pub from: Modifier, + pub to: Modifier, +} + +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))?; + } + if removed.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; + } + } + 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))?; + } + + 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))?; + } + + Ok(()) + } +} + +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; + } + + 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), + ) }