mirror of
https://github.com/openai/codex.git
synced 2026-11-13 22:47:20 +00:00
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).
This commit is contained in:
1 parent
53eb2e9f27
commit
0130a2fa40
13 files changed
+3256
-1021
No files matched your search
@@ -1,3 +1,22 @@
|
||||
//! 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;
|
||||
@@ -10,14 +29,11 @@ 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;
|
||||
@@ -97,14 +113,8 @@ where
|
||||
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)
|
||||
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))?;
|
||||
@@ -245,8 +255,8 @@ pub(crate) fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io:
|
||||
where
|
||||
I: IntoIterator<Item = &'a Span<'a>>,
|
||||
{
|
||||
let mut fg = Color::Reset;
|
||||
let mut bg = Color::Reset;
|
||||
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();
|
||||
@@ -260,13 +270,10 @@ where
|
||||
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);
|
||||
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.into(), next_bg.into()))
|
||||
)?;
|
||||
queue!(writer, SetColors(Colors::new(next_fg, next_bg)))?;
|
||||
fg = next_fg;
|
||||
bg = next_bg;
|
||||
}
|
||||
@@ -274,12 +281,7 @@ where
|
||||
queue!(writer, Print(span.content.clone()))?;
|
||||
}
|
||||
|
||||
queue!(
|
||||
writer,
|
||||
SetForegroundColor(CColor::Reset),
|
||||
SetBackgroundColor(CColor::Reset),
|
||||
SetAttribute(crossterm::style::Attribute::Reset),
|
||||
)
|
||||
queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -287,8 +289,10 @@ 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() {
|
||||
@@ -306,8 +310,6 @@ mod tests {
|
||||
Print("A"),
|
||||
SetAttribute(crossterm::style::Attribute::NormalIntensity),
|
||||
Print("B"),
|
||||
SetForegroundColor(CColor::Reset),
|
||||
SetBackgroundColor(CColor::Reset),
|
||||
SetAttribute(crossterm::style::Attribute::Reset),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -318,6 +320,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
@@ -331,7 +372,7 @@ mod tests {
|
||||
|
||||
// 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);
|
||||
line = line.style(Style::default().fg(Color::Green));
|
||||
insert_history_lines(&mut term, vec![line])
|
||||
.expect("Failed to insert history lines in test");
|
||||
|
||||
@@ -369,7 +410,7 @@ mod tests {
|
||||
"> ".into(),
|
||||
"This is a long quoted line that should wrap".into(),
|
||||
]);
|
||||
line = line.style(Color::Green);
|
||||
line = line.style(Style::default().fg(Color::Green));
|
||||
|
||||
insert_history_lines(&mut term, vec![line])
|
||||
.expect("Failed to insert history lines in test");
|
||||
|
||||
Reference in new issue
Block a user