Files
codex/codex-rs/tui2/src/insert_history.rs
T
Josh McKinney 0130a2fa40 feat(tui2): add multi-click transcript selection (#8471)
Support multi-click transcript selection using transcript/viewport
coordinates
(wrapped visual line index + content column), not terminal buffer
positions.

Gestures:
- double click: select word-ish token under cursor
- triple click: select entire wrapped line
- quad click: select paragraph (contiguous non-empty wrapped lines)
- quint+ click: select the entire history cell (all wrapped lines
belonging to a
  single `HistoryCell`, including blank lines inside the cell)

Selection expansion rebuilds the wrapped transcript view from
`HistoryCell::display_lines(width)` so boundaries match on-screen
wrapping during
scroll/resize/streaming reflow. Click grouping is resilient to minor
drag jitter
(some terminals emit tiny Drag events during clicks) and becomes more
tolerant as
the sequence progresses so quad/quint clicks are practical.

Tests cover expansion (word/line/paragraph/cell), sequence resets
(timing, motion,
line changes, real drags), drag jitter, and behavior on spacer lines
between
history cells (paragraph/cell selection prefers the cell above).
2025-12-23 21:05:06 +00:00

572 lines
21 KiB
Rust

//! Render `ratatui` transcript lines into terminal scrollback.
//!
//! `insert_history_lines` is responsible for inserting rendered transcript lines
//! *above* the TUI viewport by emitting ANSI control sequences through the
//! terminal backend writer.
//!
//! ## Why we use crossterm style commands
//!
//! `write_spans` is also used by non-terminal callers (e.g.
//! `transcript_render::render_lines_to_ansi`) to produce deterministic ANSI
//! output for tests and "print after exit" flows. That means the implementation
//! must work with any `impl Write` (including an in-memory `Vec<u8>`) and must
//! preserve `ratatui::style::Color` semantics, including `Rgb(...)` and
//! `Indexed(...)`.
//!
//! Crossterm's style commands implement `Command` (including ANSI emission), so
//! `write_spans` can remain backend-independent while still producing ANSI
//! output that matches the terminal-rendered transcript.
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::SetColors;
use crossterm::terminal::Clear;
use crossterm::terminal::ClearType;
use ratatui::layout::Size;
use ratatui::prelude::Backend;
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(Into::into).unwrap_or(CColor::Reset),
line.style.bg.map(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(())
}
}
pub(crate) fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()>
where
I: IntoIterator<Item = &'a Span<'a>>,
{
let mut fg = CColor::Reset;
let mut bg = CColor::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.map(Into::into).unwrap_or(CColor::Reset);
let next_bg = span.style.bg.map(Into::into).unwrap_or(CColor::Reset);
if next_fg != fg || next_bg != bg {
queue!(writer, SetColors(Colors::new(next_fg, next_bg)))?;
fg = next_fg;
bg = next_bg;
}
queue!(writer, Print(span.content.clone()))?;
}
queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown_render::render_markdown_text;
use crate::test_backend::VT100Backend;
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
#[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"),
SetAttribute(crossterm::style::Attribute::Reset),
)
.unwrap();
assert_eq!(
String::from_utf8(actual).unwrap(),
String::from_utf8(expected).unwrap()
);
}
#[test]
fn write_spans_emits_truecolor_and_indexed_sgr() {
// This test asserts that `write_spans` emits the correct SGR sequences for colors that
// can't be represented with the theme-aware ANSI palette:
//
// - `ratatui::style::Color::Rgb` (truecolor; `38;2;r;g;b`)
// - `ratatui::style::Color::Indexed` (256-color index; `48;5;n`)
//
// Those constructors are intentionally disallowed in production code (see
// `codex-rs/clippy.toml`), but the test needs them so the output bytes are fully
// deterministic.
#[expect(clippy::disallowed_methods)]
let fg = Color::Rgb(1, 2, 3);
#[expect(clippy::disallowed_methods)]
let bg = Color::Indexed(42);
let spans = [Span::styled("X", Style::default().fg(fg).bg(bg))];
let mut actual: Vec<u8> = Vec::new();
write_spans(&mut actual, spans.iter()).unwrap();
let mut expected: Vec<u8> = Vec::new();
queue!(
expected,
SetColors(Colors::new(
CColor::Rgb { r: 1, g: 2, b: 3 },
CColor::AnsiValue(42)
)),
Print("X"),
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(Style::default().fg(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(Style::default().fg(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()
);
}
}
}