mirror of
https://github.com/openai/codex.git
synced 2026-11-13 22:47:20 +00:00
feat(tui2): copy tui crate and normalize snapshots (#7833)
Introduce a full codex-tui source snapshot under the new codex-tui2 crate so viewport work can be replayed in isolation. This change copies the entire codex-rs/tui/src tree into codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep future diffs vs the original viewport bookmark easy to reason about. The goal is for codex-tui2 to render identically to the existing TUI behind the `features.tui2` flag while we gradually port the viewport/history commits from the joshka/viewport bookmark onto this forked tree. While on this baseline change, we also ran the codex-tui2 snapshot test suite and accepted all insta snapshots for the new crate, so the snapshot files now use the codex-tui2 naming scheme and encode the unmodified legacy TUI behavior. This keeps later viewport commits focused on intentional behavior changes (and their snapshots) rather than on mechanical snapshot renames.
This commit is contained in:
1 parent
321625072a
commit
90f262e9a4
742 files changed
+53558
-18
No files matched your search
@@ -0,0 +1,530 @@
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::wrapping::word_wrap_lines_borrowed;
|
||||
use crossterm::Command;
|
||||
use crossterm::cursor::MoveTo;
|
||||
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 crossterm::terminal::Clear;
|
||||
use crossterm::terminal::ClearType;
|
||||
use ratatui::layout::Size;
|
||||
use ratatui::prelude::Backend;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
/// Insert `lines` above the viewport using the terminal's backend writer
|
||||
/// (avoids direct stdout references).
|
||||
pub fn insert_history_lines<B>(
|
||||
terminal: &mut crate::custom_terminal::Terminal<B>,
|
||||
lines: Vec<Line>,
|
||||
) -> io::Result<()>
|
||||
where
|
||||
B: Backend + Write,
|
||||
{
|
||||
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
|
||||
|
||||
let mut area = terminal.viewport_area;
|
||||
let mut should_update_area = false;
|
||||
let last_cursor_pos = terminal.last_known_cursor_pos;
|
||||
let writer = terminal.backend_mut();
|
||||
|
||||
// Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same
|
||||
// formatting as the TUI. This avoids character-level hard wrapping by the terminal.
|
||||
let wrapped = word_wrap_lines_borrowed(&lines, area.width.max(1) as usize);
|
||||
let wrapped_lines = wrapped.len() as u16;
|
||||
let cursor_top = if area.bottom() < screen_size.height {
|
||||
// If the viewport is not at the bottom of the screen, scroll it down to make room.
|
||||
// Don't scroll it past the bottom of the screen.
|
||||
let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
|
||||
|
||||
// Emit ANSI to scroll the lower region (from the top of the viewport to the bottom
|
||||
// of the screen) downward by `scroll_amount` lines. We do this by:
|
||||
// 1) Limiting the scroll region to [area.top()+1 .. screen_height] (1-based bounds)
|
||||
// 2) Placing the cursor at the top margin of that region
|
||||
// 3) Emitting Reverse Index (RI, ESC M) `scroll_amount` times
|
||||
// 4) Resetting the scroll region back to full screen
|
||||
let top_1based = area.top() + 1; // Convert 0-based row to 1-based for DECSTBM
|
||||
queue!(writer, SetScrollRegion(top_1based..screen_size.height))?;
|
||||
queue!(writer, MoveTo(0, area.top()))?;
|
||||
for _ in 0..scroll_amount {
|
||||
// Reverse Index (RI): ESC M
|
||||
queue!(writer, Print("\x1bM"))?;
|
||||
}
|
||||
queue!(writer, ResetScrollRegion)?;
|
||||
|
||||
let cursor_top = area.top().saturating_sub(1);
|
||||
area.y += scroll_amount;
|
||||
should_update_area = true;
|
||||
cursor_top
|
||||
} else {
|
||||
area.top().saturating_sub(1)
|
||||
};
|
||||
|
||||
// Limit the scroll region to the lines from the top of the screen to the
|
||||
// top of the viewport. With this in place, when we add lines inside this
|
||||
// area, only the lines in this area will be scrolled. We place the cursor
|
||||
// at the end of the scroll region, and add lines starting there.
|
||||
//
|
||||
// ┌─Screen───────────────────────┐
|
||||
// │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│
|
||||
// │┆ ┆│
|
||||
// │┆ ┆│
|
||||
// │┆ ┆│
|
||||
// │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│
|
||||
// │╭─Viewport───────────────────╮│
|
||||
// ││ ││
|
||||
// │╰────────────────────────────╯│
|
||||
// └──────────────────────────────┘
|
||||
queue!(writer, SetScrollRegion(1..area.top()))?;
|
||||
|
||||
// NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the
|
||||
// terminal's last_known_cursor_position, which hopefully will still be accurate after we
|
||||
// fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :)
|
||||
queue!(writer, MoveTo(0, cursor_top))?;
|
||||
|
||||
for line in wrapped {
|
||||
queue!(writer, Print("\r\n"))?;
|
||||
queue!(
|
||||
writer,
|
||||
SetColors(Colors::new(
|
||||
line.style
|
||||
.fg
|
||||
.map(std::convert::Into::into)
|
||||
.unwrap_or(CColor::Reset),
|
||||
line.style
|
||||
.bg
|
||||
.map(std::convert::Into::into)
|
||||
.unwrap_or(CColor::Reset)
|
||||
))
|
||||
)?;
|
||||
queue!(writer, Clear(ClearType::UntilNewLine))?;
|
||||
// Merge line-level style into each span so that ANSI colors reflect
|
||||
// line styles (e.g., blockquotes with green fg).
|
||||
let merged_spans: Vec<Span> = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| Span {
|
||||
style: s.style.patch(line.style),
|
||||
content: s.content.clone(),
|
||||
})
|
||||
.collect();
|
||||
write_spans(writer, merged_spans.iter())?;
|
||||
}
|
||||
|
||||
queue!(writer, ResetScrollRegion)?;
|
||||
|
||||
// Restore the cursor position to where it was before we started.
|
||||
queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?;
|
||||
|
||||
let _ = writer;
|
||||
if should_update_area {
|
||||
terminal.set_viewport_area(area);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetScrollRegion(pub std::ops::Range<u16>);
|
||||
|
||||
impl Command for SetScrollRegion {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, "\x1b[{};{}r", self.0.start, self.0.end)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
panic!("tried to execute SetScrollRegion command using WinAPI, use ANSI instead");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
// TODO(nornagon): is this supported on Windows?
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ResetScrollRegion;
|
||||
|
||||
impl Command for ResetScrollRegion {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
write!(f, "\x1b[r")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
panic!("tried to execute ResetScrollRegion command using WinAPI, use ANSI instead");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_ansi_code_supported(&self) -> bool {
|
||||
// TODO(nornagon): is this supported on Windows?
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct ModifierDiff {
|
||||
pub from: Modifier,
|
||||
pub to: Modifier,
|
||||
}
|
||||
|
||||
impl ModifierDiff {
|
||||
fn queue<W>(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: IntoIterator<Item = &'a Span<'a>>,
|
||||
{
|
||||
let mut fg = Color::Reset;
|
||||
let mut bg = Color::Reset;
|
||||
let mut last_modifier = Modifier::empty();
|
||||
for span in content {
|
||||
let mut modifier = Modifier::empty();
|
||||
modifier.insert(span.style.add_modifier);
|
||||
modifier.remove(span.style.sub_modifier);
|
||||
if modifier != last_modifier {
|
||||
let diff = ModifierDiff {
|
||||
from: last_modifier,
|
||||
to: modifier,
|
||||
};
|
||||
diff.queue(&mut writer)?;
|
||||
last_modifier = 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()))?;
|
||||
}
|
||||
|
||||
queue!(
|
||||
writer,
|
||||
SetForegroundColor(CColor::Reset),
|
||||
SetBackgroundColor(CColor::Reset),
|
||||
SetAttribute(crossterm::style::Attribute::Reset),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::markdown_render::render_markdown_text;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
|
||||
#[test]
|
||||
fn writes_bold_then_regular_spans() {
|
||||
use ratatui::style::Stylize;
|
||||
|
||||
let spans = ["A".bold(), "B".into()];
|
||||
|
||||
let mut actual: Vec<u8> = Vec::new();
|
||||
write_spans(&mut actual, spans.iter()).unwrap();
|
||||
|
||||
let mut expected: Vec<u8> = Vec::new();
|
||||
queue!(
|
||||
expected,
|
||||
SetAttribute(crossterm::style::Attribute::Bold),
|
||||
Print("A"),
|
||||
SetAttribute(crossterm::style::Attribute::NormalIntensity),
|
||||
Print("B"),
|
||||
SetForegroundColor(CColor::Reset),
|
||||
SetBackgroundColor(CColor::Reset),
|
||||
SetAttribute(crossterm::style::Attribute::Reset),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
String::from_utf8(actual).unwrap(),
|
||||
String::from_utf8(expected).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_blockquote_line_emits_green_fg() {
|
||||
// Set up a small off-screen terminal
|
||||
let width: u16 = 40;
|
||||
let height: u16 = 10;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
// Place viewport on the last line so history inserts scroll upward
|
||||
let viewport = Rect::new(0, height - 1, width, 1);
|
||||
term.set_viewport_area(viewport);
|
||||
|
||||
// Build a blockquote-like line: apply line-level green style and prefix "> "
|
||||
let mut line: Line<'static> = Line::from(vec!["> ".into(), "Hello world".into()]);
|
||||
line = line.style(Color::Green);
|
||||
insert_history_lines(&mut term, vec![line])
|
||||
.expect("Failed to insert history lines in test");
|
||||
|
||||
let mut saw_colored = false;
|
||||
'outer: for row in 0..height {
|
||||
for col in 0..width {
|
||||
if let Some(cell) = term.backend().vt100().screen().cell(row, col)
|
||||
&& cell.has_contents()
|
||||
&& cell.fgcolor() != vt100::Color::Default
|
||||
{
|
||||
saw_colored = true;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_colored,
|
||||
"expected at least one colored cell in vt100 output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_blockquote_wrap_preserves_color_on_all_wrapped_lines() {
|
||||
// Force wrapping by using a narrow viewport width and a long blockquote line.
|
||||
let width: u16 = 20;
|
||||
let height: u16 = 8;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
// Viewport is the last line so history goes directly above it.
|
||||
let viewport = Rect::new(0, height - 1, width, 1);
|
||||
term.set_viewport_area(viewport);
|
||||
|
||||
// Create a long blockquote with a distinct prefix and enough text to wrap.
|
||||
let mut line: Line<'static> = Line::from(vec![
|
||||
"> ".into(),
|
||||
"This is a long quoted line that should wrap".into(),
|
||||
]);
|
||||
line = line.style(Color::Green);
|
||||
|
||||
insert_history_lines(&mut term, vec![line])
|
||||
.expect("Failed to insert history lines in test");
|
||||
|
||||
// Parse and inspect the final screen buffer.
|
||||
let screen = term.backend().vt100().screen();
|
||||
|
||||
// Collect rows that are non-empty; these should correspond to our wrapped lines.
|
||||
let mut non_empty_rows: Vec<u16> = Vec::new();
|
||||
for row in 0..height {
|
||||
let mut any = false;
|
||||
for col in 0..width {
|
||||
if let Some(cell) = screen.cell(row, col)
|
||||
&& cell.has_contents()
|
||||
&& cell.contents() != "\0"
|
||||
&& cell.contents() != " "
|
||||
{
|
||||
any = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
non_empty_rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Expect at least two rows due to wrapping.
|
||||
assert!(
|
||||
non_empty_rows.len() >= 2,
|
||||
"expected wrapped output to span >=2 rows, got {non_empty_rows:?}",
|
||||
);
|
||||
|
||||
// For each non-empty row, ensure all non-space cells are using a non-default fg color.
|
||||
for row in non_empty_rows {
|
||||
for col in 0..width {
|
||||
if let Some(cell) = screen.cell(row, col) {
|
||||
let contents = cell.contents();
|
||||
if !contents.is_empty() && contents != " " {
|
||||
assert!(
|
||||
cell.fgcolor() != vt100::Color::Default,
|
||||
"expected non-default fg on row {row} col {col}, got {:?}",
|
||||
cell.fgcolor()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_colored_prefix_then_plain_text_resets_color() {
|
||||
let width: u16 = 40;
|
||||
let height: u16 = 6;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
let viewport = Rect::new(0, height - 1, width, 1);
|
||||
term.set_viewport_area(viewport);
|
||||
|
||||
// First span colored, rest plain.
|
||||
let line: Line<'static> = Line::from(vec![
|
||||
Span::styled("1. ", ratatui::style::Style::default().fg(Color::LightBlue)),
|
||||
Span::raw("Hello world"),
|
||||
]);
|
||||
|
||||
insert_history_lines(&mut term, vec![line])
|
||||
.expect("Failed to insert history lines in test");
|
||||
|
||||
let screen = term.backend().vt100().screen();
|
||||
|
||||
// Find the first non-empty row; verify first three cells are colored, following cells default.
|
||||
'rows: for row in 0..height {
|
||||
let mut has_text = false;
|
||||
for col in 0..width {
|
||||
if let Some(cell) = screen.cell(row, col)
|
||||
&& cell.has_contents()
|
||||
&& cell.contents() != " "
|
||||
{
|
||||
has_text = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !has_text {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expect "1. Hello world" starting at col 0.
|
||||
for col in 0..3 {
|
||||
let cell = screen.cell(row, col).unwrap();
|
||||
assert!(
|
||||
cell.fgcolor() != vt100::Color::Default,
|
||||
"expected colored prefix at col {col}, got {:?}",
|
||||
cell.fgcolor()
|
||||
);
|
||||
}
|
||||
for col in 3..(3 + "Hello world".len() as u16) {
|
||||
let cell = screen.cell(row, col).unwrap();
|
||||
assert_eq!(
|
||||
cell.fgcolor(),
|
||||
vt100::Color::Default,
|
||||
"expected default color for plain text at col {col}, got {:?}",
|
||||
cell.fgcolor()
|
||||
);
|
||||
}
|
||||
break 'rows;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vt100_deep_nested_mixed_list_third_level_marker_is_colored() {
|
||||
// Markdown with five levels (ordered → unordered → ordered → unordered → unordered).
|
||||
let md = "1. First\n - Second level\n 1. Third level (ordered)\n - Fourth level (bullet)\n - Fifth level to test indent consistency\n";
|
||||
let text = render_markdown_text(md);
|
||||
let lines: Vec<Line<'static>> = text.lines.clone();
|
||||
|
||||
let width: u16 = 60;
|
||||
let height: u16 = 12;
|
||||
let backend = VT100Backend::new(width, height);
|
||||
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
|
||||
let viewport = ratatui::layout::Rect::new(0, height - 1, width, 1);
|
||||
term.set_viewport_area(viewport);
|
||||
|
||||
insert_history_lines(&mut term, lines).expect("Failed to insert history lines in test");
|
||||
|
||||
let screen = term.backend().vt100().screen();
|
||||
|
||||
// Reconstruct screen rows as strings to locate the 3rd level line.
|
||||
let rows: Vec<String> = screen.rows(0, width).collect();
|
||||
|
||||
let needle = "1. Third level (ordered)";
|
||||
let row_idx = rows
|
||||
.iter()
|
||||
.position(|r| r.contains(needle))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("expected to find row containing {needle:?}, have rows: {rows:?}")
|
||||
});
|
||||
let col_start = rows[row_idx].find(needle).unwrap() as u16; // column where '1' starts
|
||||
|
||||
// Verify that the numeric marker ("1.") at the third level is colored
|
||||
// (non-default fg) and the content after the following space resets to default.
|
||||
for c in [col_start, col_start + 1] {
|
||||
let cell = screen.cell(row_idx as u16, c).unwrap();
|
||||
assert!(
|
||||
cell.fgcolor() != vt100::Color::Default,
|
||||
"expected colored 3rd-level marker at row {row_idx} col {c}, got {:?}",
|
||||
cell.fgcolor()
|
||||
);
|
||||
}
|
||||
let content_col = col_start + 3; // skip '1', '.', and the space
|
||||
if let Some(cell) = screen.cell(row_idx as u16, content_col) {
|
||||
assert_eq!(
|
||||
cell.fgcolor(),
|
||||
vt100::Color::Default,
|
||||
"expected default color for 3rd-level content at row {row_idx} col {content_col}, got {:?}",
|
||||
cell.fgcolor()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user