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:
Josh McKinney authored and GitHub committed 2025-12-23 21:05:06 +00:00
1 parent 53eb2e9f27
commit 0130a2fa40
13 files changed
+3256 -1021

No files matched your search

+53 -164
View File
@@ -17,7 +17,8 @@ use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::Renderable;
use crate::resume_picker::ResumeSelection;
use crate::transcript_copy::TranscriptCopyUi;
use crate::transcript_copy_ui::TranscriptCopyUi;
use crate::transcript_multi_click::TranscriptMultiClick;
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
use crate::transcript_selection::TranscriptSelection;
use crate::transcript_selection::TranscriptSelectionPoint;
@@ -31,9 +32,6 @@ use crate::tui::scrolling::ScrollUpdate;
use crate::tui::scrolling::TranscriptLineMeta;
use crate::tui::scrolling::TranscriptScroll;
use crate::update_action::UpdateAction;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines_borrowed;
use codex_ansi_escape::ansi_escape_line;
use codex_core::AuthManager;
use codex_core::ConversationManager;
@@ -80,7 +78,6 @@ use std::thread;
use std::time::Duration;
use tokio::select;
use tokio::sync::mpsc::unbounded_channel;
use unicode_width::UnicodeWidthStr;
#[cfg(not(debug_assertions))]
use crate::history_cell::UpdateAvailableHistoryCell;
@@ -333,6 +330,7 @@ pub(crate) struct App {
#[allow(dead_code)]
transcript_scroll: TranscriptScroll,
transcript_selection: TranscriptSelection,
transcript_multi_click: TranscriptMultiClick,
transcript_view_top: usize,
transcript_total_lines: usize,
transcript_copy_ui: TranscriptCopyUi,
@@ -363,7 +361,6 @@ pub(crate) struct App {
// One-shot suppression of the next world-writable scan after user confirmation.
skip_world_writable_scan_once: bool,
}
impl App {
async fn shutdown_current_conversation(&mut self) {
if let Some(conversation_id) = self.chat_widget.conversation_id() {
@@ -486,7 +483,7 @@ impl App {
},
);
let copy_selection_shortcut = crate::transcript_copy::detect_copy_selection_shortcut();
let copy_selection_shortcut = crate::transcript_copy_ui::detect_copy_selection_shortcut();
let mut app = Self {
server: conversation_manager.clone(),
@@ -501,6 +498,7 @@ impl App {
transcript_cells: Vec::new(),
transcript_scroll: TranscriptScroll::default(),
transcript_selection: TranscriptSelection::default(),
transcript_multi_click: TranscriptMultiClick::default(),
transcript_view_top: 0,
transcript_total_lines: 0,
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut),
@@ -570,13 +568,15 @@ impl App {
let session_lines = if width == 0 {
Vec::new()
} else {
let (lines, line_meta) = Self::build_transcript_lines(&app.transcript_cells, width);
let transcript =
crate::transcript_render::build_transcript_lines(&app.transcript_cells, width);
let (lines, line_meta) = (transcript.lines, transcript.meta);
let is_user_cell: Vec<bool> = app
.transcript_cells
.iter()
.map(|cell| cell.as_any().is::<UserHistoryCell>())
.collect();
Self::render_lines_to_ansi(&lines, &line_meta, &is_user_cell, width)
crate::transcript_render::render_lines_to_ansi(&lines, &line_meta, &is_user_cell, width)
};
tui.terminal.clear()?;
@@ -711,7 +711,9 @@ impl App {
height: max_transcript_height,
};
let (lines, line_meta) = Self::build_transcript_lines(cells, transcript_area.width);
let transcript =
crate::transcript_render::build_wrapped_transcript_lines(cells, transcript_area.width);
let (lines, line_meta) = (transcript.lines, transcript.meta);
if lines.is_empty() {
Clear.render_ref(transcript_area, frame.buffer);
self.transcript_scroll = TranscriptScroll::default();
@@ -720,40 +722,12 @@ impl App {
return area.y;
}
let wrapped = word_wrap_lines_borrowed(&lines, transcript_area.width.max(1) as usize);
if wrapped.is_empty() {
self.transcript_scroll = TranscriptScroll::default();
self.transcript_view_top = 0;
self.transcript_total_lines = 0;
return area.y;
}
let is_user_cell: Vec<bool> = cells
.iter()
.map(|c| c.as_any().is::<UserHistoryCell>())
.collect();
let base_opts: RtOptions<'_> = RtOptions::new(transcript_area.width.max(1) as usize);
let mut wrapped_is_user_row: Vec<bool> = Vec::with_capacity(wrapped.len());
let mut first = true;
for (idx, line) in lines.iter().enumerate() {
let opts = if first {
base_opts.clone()
} else {
base_opts
.clone()
.initial_indent(base_opts.subsequent_indent.clone())
};
let seg_count = word_wrap_line(line, opts).len();
let is_user_row = line_meta
.get(idx)
.and_then(TranscriptLineMeta::cell_index)
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
.unwrap_or(false);
wrapped_is_user_row.extend(std::iter::repeat_n(is_user_row, seg_count));
first = false;
}
let total_lines = wrapped.len();
let total_lines = lines.len();
self.transcript_total_lines = total_lines;
let max_visible = std::cmp::min(max_transcript_height as usize, total_lines);
let max_start = total_lines.saturating_sub(max_visible);
@@ -805,11 +779,12 @@ impl App {
height: 1,
};
if wrapped_is_user_row
let is_user_row = line_meta
.get(line_index)
.copied()
.unwrap_or(false)
{
.and_then(TranscriptLineMeta::cell_index)
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
.unwrap_or(false);
if is_user_row {
let base_style = crate::style::user_message_style();
for x in row_area.x..row_area.right() {
let cell = &mut frame.buffer[(x, y)];
@@ -818,7 +793,7 @@ impl App {
}
}
wrapped[line_index].render_ref(row_area, frame.buffer);
lines[line_index].render_ref(row_area, frame.buffer);
}
self.apply_transcript_selection(transcript_area, frame.buffer);
@@ -965,8 +940,12 @@ impl App {
clamped_x,
clamped_y,
);
if crate::transcript_selection::on_mouse_down(&mut self.transcript_selection, point)
{
if self.transcript_multi_click.on_mouse_down(
&mut self.transcript_selection,
&self.transcript_cells,
transcript_area.width,
point,
) {
tui.frame_requester().schedule_frame();
}
}
@@ -983,6 +962,8 @@ impl App {
point,
streaming,
);
self.transcript_multi_click
.on_mouse_drag(&self.transcript_selection, point);
if outcome.lock_scroll {
self.lock_transcript_scroll_to_current_view(
transcript_area.height as usize,
@@ -1125,7 +1106,9 @@ impl App {
return;
}
let (_, line_meta) = Self::build_transcript_lines(&self.transcript_cells, width);
let transcript =
crate::transcript_render::build_wrapped_transcript_lines(&self.transcript_cells, width);
let line_meta = transcript.meta;
self.transcript_scroll =
self.transcript_scroll
.scrolled_by(delta_lines, &line_meta, visible_lines);
@@ -1149,7 +1132,9 @@ impl App {
return;
}
let (lines, line_meta) = Self::build_transcript_lines(&self.transcript_cells, width);
let transcript =
crate::transcript_render::build_wrapped_transcript_lines(&self.transcript_cells, width);
let (lines, line_meta) = (transcript.lines, transcript.meta);
if lines.is_empty() || line_meta.is_empty() {
return;
}
@@ -1174,111 +1159,6 @@ impl App {
}
}
/// Build the flattened transcript lines for rendering, scrolling, and exit transcripts.
///
/// Returns both the visible `Line` buffer and a parallel metadata vector
/// that maps each line back to its originating `(cell_index, line_in_cell)`
/// pair (see `TranscriptLineMeta::CellLine`), or `TranscriptLineMeta::Spacer` for
/// synthetic spacer rows inserted between cells. This allows the scroll state
/// to anchor to a specific history cell even as new content arrives or the
/// viewport size changes, and gives exit transcript renderers enough structure
/// to style user rows differently from agent rows.
fn build_transcript_lines(
cells: &[Arc<dyn HistoryCell>],
width: u16,
) -> (Vec<Line<'static>>, Vec<TranscriptLineMeta>) {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut line_meta: Vec<TranscriptLineMeta> = Vec::new();
let mut has_emitted_lines = false;
for (cell_index, cell) in cells.iter().enumerate() {
let cell_lines = cell.display_lines(width);
if cell_lines.is_empty() {
continue;
}
if !cell.is_stream_continuation() {
if has_emitted_lines {
lines.push(Line::from(""));
line_meta.push(TranscriptLineMeta::Spacer);
} else {
has_emitted_lines = true;
}
}
for (line_in_cell, line) in cell_lines.into_iter().enumerate() {
line_meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
});
lines.push(line);
}
}
(lines, line_meta)
}
/// Render flattened transcript lines into ANSI strings suitable for
/// printing after the TUI exits.
///
/// This helper mirrors the original TUI viewport behavior:
/// - Merges line-level style into each span so the ANSI output matches
/// the on-screen styling (e.g., blockquotes, lists).
/// - For user-authored rows, pads the background style out to the full
/// terminal width so prompts appear as solid blocks in scrollback.
/// - Streams spans through the shared vt100 writer so downstream tests
/// and tools see consistent escape sequences.
fn render_lines_to_ansi(
lines: &[Line<'static>],
line_meta: &[TranscriptLineMeta],
is_user_cell: &[bool],
width: u16,
) -> Vec<String> {
lines
.iter()
.enumerate()
.map(|(idx, line)| {
let is_user_row = line_meta
.get(idx)
.and_then(TranscriptLineMeta::cell_index)
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
.unwrap_or(false);
let mut merged_spans: Vec<ratatui::text::Span<'static>> = line
.spans
.iter()
.map(|span| ratatui::text::Span {
style: span.style.patch(line.style),
content: span.content.clone(),
})
.collect();
if is_user_row && width > 0 {
let text: String = merged_spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let text_width = UnicodeWidthStr::width(text.as_str());
let total_width = usize::from(width);
if text_width < total_width {
let pad_len = total_width.saturating_sub(text_width);
if pad_len > 0 {
let pad_style = crate::style::user_message_style();
merged_spans.push(ratatui::text::Span {
style: pad_style,
content: " ".repeat(pad_len).into(),
});
}
}
}
let mut buf: Vec<u8> = Vec::new();
let _ = crate::insert_history::write_spans(&mut buf, merged_spans.iter());
String::from_utf8(buf).unwrap_or_default()
})
.collect()
}
/// Apply the current transcript selection to the given buffer.
///
/// The selection is defined in terms of flattened wrapped transcript line
@@ -1401,13 +1281,13 @@ impl App {
return;
}
let (lines, _) = Self::build_transcript_lines(&self.transcript_cells, width);
let Some(text) =
crate::transcript_selection::selection_text(&lines, self.transcript_selection, width)
else {
let Some(text) = crate::transcript_copy::selection_to_copy_text_for_cells(
&self.transcript_cells,
self.transcript_selection,
width,
) else {
return;
};
if let Err(err) = clipboard_copy::copy_text(text) {
tracing::error!(error = %err, "failed to copy selection to clipboard");
}
@@ -2189,7 +2069,7 @@ mod tests {
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::new_session_info;
use crate::transcript_copy::CopySelectionShortcut;
use crate::transcript_copy_ui::CopySelectionShortcut;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ConversationManager;
@@ -2229,6 +2109,7 @@ mod tests {
transcript_cells: Vec::new(),
transcript_scroll: TranscriptScroll::default(),
transcript_selection: TranscriptSelection::default(),
transcript_multi_click: TranscriptMultiClick::default(),
transcript_view_top: 0,
transcript_total_lines: 0,
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
@@ -2278,6 +2159,7 @@ mod tests {
transcript_cells: Vec::new(),
transcript_scroll: TranscriptScroll::default(),
transcript_selection: TranscriptSelection::default(),
transcript_multi_click: TranscriptMultiClick::default(),
transcript_view_top: 0,
transcript_total_lines: 0,
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
@@ -2363,10 +2245,12 @@ mod tests {
column: u16::MAX,
});
let (lines, _) = App::build_transcript_lines(&app.transcript_cells, 40);
let text =
crate::transcript_selection::selection_text(&lines, app.transcript_selection, 40)
.unwrap();
let text = crate::transcript_copy::selection_to_copy_text_for_cells(
&app.transcript_cells,
app.transcript_selection,
40,
)
.expect("expected text");
assert_eq!(text, "one\ntwo\nthree\nfour");
}
@@ -2766,7 +2650,12 @@ mod tests {
let is_user_cell = vec![true];
let width: u16 = 10;
let rendered = App::render_lines_to_ansi(&lines, &line_meta, &is_user_cell, width);
let rendered = crate::transcript_render::render_lines_to_ansi(
&lines,
&line_meta,
&is_user_cell,
width,
);
assert_eq!(rendered.len(), 1);
assert!(rendered[0].contains("hi"));
}
+153 -28
View File
@@ -10,7 +10,6 @@ use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::text_formatting::format_and_truncate_tool_result;
@@ -21,7 +20,6 @@ use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
use base64::Engine;
use codex_common::format_env_display::format_env_display;
use codex_core::config::Config;
@@ -58,6 +56,47 @@ use std::time::Instant;
use tracing::error;
use unicode_width::UnicodeWidthStr;
/// Visual transcript lines plus soft-wrap joiners.
///
/// A history cell can produce multiple "visual lines" once prefixes/indents and wrapping are
/// applied. Clipboard reconstruction needs more information than just those lines: users expect
/// soft-wrapped prose to copy as a single logical line, while explicit newlines and spacer rows
/// should remain hard breaks.
///
/// `joiner_before` records, for each output line, whether it is a continuation created by the
/// wrapping algorithm and what string should be inserted at the wrap boundary when joining lines.
/// This avoids heuristics like always inserting a space, and instead preserves the exact whitespace
/// that was skipped at the boundary.
///
/// ## Note for `codex-tui` vs `codex-tui2`
///
/// In `codex-tui`, `HistoryCell` only exposes `transcript_lines(...)` and the UI generally doesn't
/// need to reconstruct clipboard text across off-screen history or soft-wrap boundaries.
///
/// In `codex-tui2`, transcript selection and copy are app-driven (not terminal-driven) and may span
/// content that isn't currently visible. That means we need additional metadata to distinguish hard
/// breaks from soft wraps and to preserve the exact whitespace at wrap boundaries.
///
/// Invariants:
/// - `joiner_before.len() == lines.len()`
/// - `joiner_before[0]` is always `None`
/// - `None` represents a hard break
/// - `Some(joiner)` represents a soft wrap continuation
///
/// Consumers:
/// - `transcript_render` threads joiners through transcript flattening/wrapping.
/// - `transcript_copy` uses them to join wrapped prose while preserving hard breaks.
#[derive(Debug, Clone)]
pub(crate) struct TranscriptLinesWithJoiners {
/// Visual transcript lines for a history cell, including any indent/prefix spans.
///
/// This is the same shape used for on-screen transcript rendering: a single cell may expand
/// to multiple `Line`s after wrapping and prefixing.
pub(crate) lines: Vec<Line<'static>>,
/// For each output line, whether and how to join it to the previous line when copying.
pub(crate) joiner_before: Vec<Option<String>>,
}
/// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list.
@@ -76,6 +115,19 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
self.display_lines(width)
}
/// Transcript lines plus soft-wrap joiners used for copy/paste fidelity.
///
/// Most cells can use the default implementation (no joiners), but cells that apply wrapping
/// should override this and return joiners derived from the same wrapping operation so
/// clipboard reconstruction can distinguish hard breaks from soft wraps.
fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
let lines = self.transcript_lines(width);
TranscriptLinesWithJoiners {
joiner_before: vec![None; lines.len()],
lines,
}
}
fn desired_transcript_height(&self, width: u16) -> u16 {
let lines = self.transcript_lines(width);
// Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines.
@@ -135,8 +187,10 @@ pub(crate) struct UserHistoryCell {
impl HistoryCell for UserHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
self.transcript_lines_with_joiners(width).lines
}
fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
let wrap_width = width
.saturating_sub(
LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */
@@ -145,17 +199,32 @@ impl HistoryCell for UserHistoryCell {
let style = user_message_style();
let wrapped = word_wrap_lines(
let (wrapped, joiner_before) = crate::wrapping::word_wrap_lines_with_joiners(
self.message.lines().map(|l| Line::from(l).style(style)),
// Wrap algorithm matches textarea.rs.
RtOptions::new(usize::from(wrap_width))
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut joins: Vec<Option<String>> = Vec::new();
lines.push(Line::from("").style(style));
lines.extend(prefix_lines(wrapped, "› ".bold().dim(), " ".into()));
joins.push(None);
let prefixed = prefix_lines(wrapped, "› ".bold().dim(), " ".into());
for (line, joiner) in prefixed.into_iter().zip(joiner_before) {
lines.push(line);
joins.push(joiner);
}
lines.push(Line::from("").style(style));
lines
joins.push(None);
TranscriptLinesWithJoiners {
lines,
joiner_before: joins,
}
}
}
@@ -176,6 +245,10 @@ impl ReasoningSummaryCell {
}
fn lines(&self, width: u16) -> Vec<Line<'static>> {
self.lines_with_joiners(width).lines
}
fn lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
let mut lines: Vec<Line<'static>> = Vec::new();
append_markdown(
&self.content,
@@ -195,12 +268,17 @@ impl ReasoningSummaryCell {
})
.collect::<Vec<_>>();
word_wrap_lines(
let (lines, joiner_before) = crate::wrapping::word_wrap_lines_with_joiners(
&summary_lines,
RtOptions::new(width as usize)
.initial_indent("• ".dim().into())
.subsequent_indent(" ".into()),
)
);
TranscriptLinesWithJoiners {
lines,
joiner_before,
}
}
}
@@ -225,6 +303,10 @@ impl HistoryCell for ReasoningSummaryCell {
self.lines(width)
}
fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
self.lines_with_joiners(width)
}
fn desired_transcript_height(&self, width: u16) -> u16 {
self.lines(width).len() as u16
}
@@ -247,16 +329,50 @@ impl AgentMessageCell {
impl HistoryCell for AgentMessageCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
word_wrap_lines(
&self.lines,
RtOptions::new(width as usize)
.initial_indent(if self.is_first_line {
"• ".dim().into()
} else {
" ".into()
})
.subsequent_indent(" ".into()),
)
self.transcript_lines_with_joiners(width).lines
}
fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
use ratatui::style::Color;
let mut out_lines: Vec<Line<'static>> = Vec::new();
let mut joiner_before: Vec<Option<String>> = Vec::new();
let mut is_first_output_line = true;
for line in &self.lines {
let is_code_block_line = line.style.fg == Some(Color::Cyan);
let initial_indent: Line<'static> = if is_first_output_line && self.is_first_line {
"• ".dim().into()
} else {
" ".into()
};
let subsequent_indent: Line<'static> = " ".into();
if is_code_block_line {
let mut spans = initial_indent.spans;
spans.extend(line.spans.iter().cloned());
out_lines.push(Line::from(spans).style(line.style));
joiner_before.push(None);
is_first_output_line = false;
continue;
}
let opts = RtOptions::new(width as usize)
.initial_indent(initial_indent)
.subsequent_indent(subsequent_indent.clone());
let (wrapped, wrapped_joiners) =
crate::wrapping::word_wrap_line_with_joiners(line, opts);
for (l, j) in wrapped.into_iter().zip(wrapped_joiners) {
out_lines.push(line_to_static(&l));
joiner_before.push(j);
is_first_output_line = false;
}
}
TranscriptLinesWithJoiners {
lines: out_lines,
joiner_before,
}
}
fn is_stream_continuation(&self) -> bool {
@@ -358,21 +474,30 @@ impl PrefixedWrappedHistoryCell {
impl HistoryCell for PrefixedWrappedHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
}
let opts = RtOptions::new(width.max(1) as usize)
.initial_indent(self.initial_prefix.clone())
.subsequent_indent(self.subsequent_prefix.clone());
let wrapped = word_wrap_lines(&self.text, opts);
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
self.transcript_lines_with_joiners(width).lines
}
fn desired_height(&self, width: u16) -> u16 {
self.display_lines(width).len() as u16
}
fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
if width == 0 {
return TranscriptLinesWithJoiners {
lines: Vec::new(),
joiner_before: Vec::new(),
};
}
let opts = RtOptions::new(width.max(1) as usize)
.initial_indent(self.initial_prefix.clone())
.subsequent_indent(self.subsequent_prefix.clone());
let (lines, joiner_before) =
crate::wrapping::word_wrap_lines_with_joiners(&self.text, opts);
TranscriptLinesWithJoiners {
lines,
joiner_before,
}
}
}
fn truncate_exec_snippet(full_cmd: &str) -> String {
+70 -29
View File
@@ -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");
+3
View File
@@ -78,6 +78,9 @@ mod terminal_palette;
mod text_formatting;
mod tooltips;
mod transcript_copy;
mod transcript_copy_ui;
mod transcript_multi_click;
mod transcript_render;
mod transcript_selection;
mod tui;
mod ui_consts;
+9 -1
View File
@@ -468,11 +468,19 @@ where
.indent_stack
.iter()
.any(|ctx| ctx.prefix.iter().any(|s| s.content.contains('>')));
let style = if blockquote_active {
let mut style = if blockquote_active {
self.styles.blockquote
} else {
line.style
};
// Code blocks are "preformatted": we want them to keep code styling even when they appear
// within other structures like blockquotes (which otherwise apply a line-level style).
//
// This matters for copy fidelity: downstream copy logic uses code styling as a cue to
// preserve indentation and to fence code runs with Markdown markers.
if self.in_code_block {
style = style.patch(self.styles.code);
}
let was_pending = self.pending_marker_line;
self.current_initial_indent = self.prefix_spans(was_pending);
+21 -28
View File
@@ -1,4 +1,5 @@
use pretty_assertions::assert_eq;
use ratatui::style::Color;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -382,34 +383,20 @@ fn blockquote_heading_inherits_heading_style() {
fn blockquote_with_code_block() {
let md = "> ```\n> code\n> ```\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["> code".to_string()]);
assert_eq!(text.lines, [Line::from_iter(["> ", "", "code"]).cyan()]);
}
#[test]
fn blockquote_with_multiline_code_block() {
let md = "> ```\n> first\n> second\n> ```\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["> first", "> second"]);
assert_eq!(
text.lines,
[
Line::from_iter(["> ", "", "first"]).cyan(),
Line::from_iter(["> ", "", "second"]).cyan(),
]
);
}
#[test]
@@ -453,6 +440,12 @@ fn nested_blockquote_with_inline_and_fenced_code() {
"> > echo \"hello from a quote\"".to_string(),
]
);
// Fenced code inside nested blockquotes should keep code styling so copy logic can treat it as
// preformatted.
for idx in [4usize, 5usize] {
assert_eq!(text.lines[idx].style.fg, Some(Color::Cyan));
}
}
#[test]
@@ -654,7 +647,7 @@ fn link() {
#[test]
fn code_block_unhighlighted() {
let text = render_markdown_text("```rust\nfn main() {}\n```\n");
let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"])]);
let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"]).cyan()]);
assert_eq!(text, expected);
}
@@ -663,8 +656,8 @@ fn code_block_multiple_lines_root() {
let md = "```\nfirst\nsecond\n```\n";
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["", "first"]),
Line::from_iter(["", "second"]),
Line::from_iter(["", "first"]).cyan(),
Line::from_iter(["", "second"]).cyan(),
]);
assert_eq!(text, expected);
}
@@ -674,9 +667,9 @@ fn code_block_indented() {
let md = " function greet() {\n console.log(\"Hi\");\n }\n";
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter([" ", "function greet() {"]),
Line::from_iter([" ", " console.log(\"Hi\");"]),
Line::from_iter([" ", "}"]),
Line::from_iter([" ", "function greet() {"]).cyan(),
Line::from_iter([" ", " console.log(\"Hi\");"]).cyan(),
Line::from_iter([" ", "}"]).cyan(),
]);
assert_eq!(text, expected);
}
File diff suppressed because it is too large. Load diff
+332
View File
@@ -0,0 +1,332 @@
//! Transcript-selection copy UX helpers.
//!
//! # Background
//!
//! TUI2 owns a logical transcript viewport (with history that can live outside the visible buffer),
//! plus its own selection model. Terminal-native selection/copy does not work reliably in this
//! setup because:
//!
//! - The selection can extend outside the current viewport, while terminal selection can't.
//! - We want to exclude non-content regions (like the left gutter) from copied text.
//! - The terminal may intercept some keybindings before the app ever sees them.
//!
//! This module centralizes:
//!
//! - The effective "copy selection" shortcut (so the footer and affordance stay in sync).
//! - Key matching for triggering copy (with terminal quirks handled in one place).
//! - A small on-screen clickable "⧉ copy …" pill rendered near the current selection.
//!
//! # VS Code shortcut rationale
//!
//! VS Code's integrated terminal commonly captures `Ctrl+Shift+C` for its own copy behavior and
//! does not forward the keypress to applications running inside the terminal. Since we can't
//! observe it via crossterm, we advertise and accept `Ctrl+Y` in that environment.
//!
//! Clipboard text reconstruction (preserving indentation, joining soft-wrapped
//! prose, and emitting Markdown source markers) lives in `transcript_copy`.
use codex_core::terminal::TerminalName;
use codex_core::terminal::terminal_info;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
/// The shortcut we advertise and accept for "copy selection".
pub(crate) enum CopySelectionShortcut {
CtrlShiftC,
CtrlY,
}
/// Returns the best shortcut to advertise/accept for "copy selection".
///
/// VS Code's integrated terminal typically captures `Ctrl+Shift+C` for its own copy behavior and
/// does not forward it to applications running inside the terminal. That means we can't reliably
/// observe it via crossterm, so we use `Ctrl+Y` there.
///
/// We use both the terminal name (when available) and `VSCODE_IPC_HOOK_CLI` because the terminal
/// name can be `Unknown` early during startup in some environments.
pub(crate) fn detect_copy_selection_shortcut() -> CopySelectionShortcut {
let info = terminal_info();
if info.name == TerminalName::VsCode || std::env::var_os("VSCODE_IPC_HOOK_CLI").is_some() {
return CopySelectionShortcut::CtrlY;
}
CopySelectionShortcut::CtrlShiftC
}
pub(crate) fn key_binding_for(shortcut: CopySelectionShortcut) -> KeyBinding {
match shortcut {
CopySelectionShortcut::CtrlShiftC => key_hint::ctrl_shift(KeyCode::Char('c')),
CopySelectionShortcut::CtrlY => key_hint::ctrl(KeyCode::Char('y')),
}
}
/// Whether the given `(ch, modifiers)` should trigger "copy selection".
///
/// Terminal/event edge cases:
/// - Some terminals report `Ctrl+Shift+C` as `Char('C')` with `CONTROL` only, baking the shift into
/// the character. We accept both `c` and `C` in `CtrlShiftC` mode (including VS Code).
/// - Some environments intercept `Ctrl+Shift+C` before the app sees it. We keep `Ctrl+Y` as a
/// fallback in `CtrlShiftC` mode to preserve a working key path.
pub(crate) fn is_copy_selection_key(
shortcut: CopySelectionShortcut,
ch: char,
modifiers: KeyModifiers,
) -> bool {
if !modifiers.contains(KeyModifiers::CONTROL) {
return false;
}
match shortcut {
CopySelectionShortcut::CtrlY => ch == 'y' && modifiers == KeyModifiers::CONTROL,
CopySelectionShortcut::CtrlShiftC => {
(matches!(ch, 'c' | 'C') && (modifiers.contains(KeyModifiers::SHIFT) || ch == 'C'))
// Fallback for environments that intercept Ctrl+Shift+C.
|| (ch == 'y' && modifiers == KeyModifiers::CONTROL)
}
}
}
/// UI state for the on-screen copy affordance shown near an active selection.
///
/// This tracks a `Rect` for hit-testing so we can treat the pill as a clickable button.
#[derive(Debug)]
pub(crate) struct TranscriptCopyUi {
shortcut: CopySelectionShortcut,
dragging: bool,
affordance_rect: Option<Rect>,
}
impl TranscriptCopyUi {
/// Creates a new instance using the provided shortcut.
pub(crate) fn new_with_shortcut(shortcut: CopySelectionShortcut) -> Self {
Self {
shortcut,
dragging: false,
affordance_rect: None,
}
}
pub(crate) fn key_binding(&self) -> KeyBinding {
key_binding_for(self.shortcut)
}
pub(crate) fn is_copy_key(&self, ch: char, modifiers: KeyModifiers) -> bool {
is_copy_selection_key(self.shortcut, ch, modifiers)
}
pub(crate) fn set_dragging(&mut self, dragging: bool) {
self.dragging = dragging;
}
pub(crate) fn clear_affordance(&mut self) {
self.affordance_rect = None;
}
/// Returns `true` if the last rendered pill contains `(x, y)`.
///
/// `render_copy_pill()` sets `affordance_rect` and `clear_affordance()` clears it, so callers
/// should treat this as "hit test against the current frame's affordance".
pub(crate) fn hit_test(&self, x: u16, y: u16) -> bool {
self.affordance_rect
.is_some_and(|r| x >= r.x && x < r.right() && y >= r.y && y < r.bottom())
}
/// Render the copy "pill" just below the visible end of the selection.
///
/// Inputs are expressed in logical transcript coordinates:
/// - `anchor`/`head`: `(line_index, column)` in the wrapped transcript (not screen rows).
/// - `view_top`: first logical line index currently visible in `area`.
/// - `total_lines`: total number of logical transcript lines.
///
/// Placement details / edge cases:
/// - We hide the pill while dragging to avoid accidental clicks during selection updates.
/// - We only render if some part of the selection is visible, and there's room for a line
/// below it inside `area`.
/// - We scan the buffer to find the last non-space cell on each candidate row so the pill can
/// sit "near content", not far to the right past trailing whitespace.
///
/// Important: this assumes the transcript content has already been rendered into `buf` for the
/// current frame, since the placement logic derives `text_end` by inspecting buffer contents.
pub(crate) fn render_copy_pill(
&mut self,
area: Rect,
buf: &mut Buffer,
anchor: (usize, u16),
head: (usize, u16),
view_top: usize,
total_lines: usize,
) {
// Reset every frame. If we don't render (e.g. selection is off-screen) we shouldn't keep
// an old hit target around.
self.affordance_rect = None;
if self.dragging || total_lines == 0 {
return;
}
// Skip the transcript gutter (line numbers, diff markers, etc.). Selection/copy operates on
// transcript content only.
let base_x = area.x.saturating_add(TRANSCRIPT_GUTTER_COLS);
let max_x = area.right().saturating_sub(1);
if base_x > max_x {
return;
}
// Normalize to a start/end pair so the rest of the code can assume forward order.
let mut start = anchor;
let mut end = head;
if (end.0 < start.0) || (end.0 == start.0 && end.1 < start.1) {
std::mem::swap(&mut start, &mut end);
}
// We want to place the pill *near the visible end of the selection*, which means:
// - Find the last visible transcript line that intersects the selection.
// - Find the rightmost selected column on that line (clamped to actual rendered text).
// - Place the pill one row below that point.
let visible_start = view_top;
let visible_end = view_top
.saturating_add(area.height as usize)
.min(total_lines);
let mut last_visible_segment: Option<(u16, u16)> = None;
for (row_index, line_index) in (visible_start..visible_end).enumerate() {
// Skip lines outside the selection range.
if line_index < start.0 || line_index > end.0 {
continue;
}
let y = area.y + row_index as u16;
// Look for the rightmost non-space cell on this row so we can clamp the pill placement
// to real content. (The transcript renderer often pads the row with spaces.)
let mut last_text_x = None;
for x in base_x..=max_x {
let cell = &buf[(x, y)];
if cell.symbol() != " " {
last_text_x = Some(x);
}
}
let Some(text_end) = last_text_x else {
continue;
};
let line_end_col = if line_index == end.0 {
end.1
} else {
// For multi-line selections, treat intermediate lines as selected "to the end" so
// the pill doesn't jump left unexpectedly when only the final line has an explicit
// end column.
max_x.saturating_sub(base_x)
};
let row_sel_end = base_x.saturating_add(line_end_col).min(max_x);
if row_sel_end < base_x {
continue;
}
// Clamp the selection end to `text_end` so we don't place the pill far to the right on
// lines that are mostly blank (or padded).
let to_x = row_sel_end.min(text_end);
last_visible_segment = Some((y, to_x));
}
// If nothing in the selection is visible, don't show the affordance.
let Some((y, to_x)) = last_visible_segment else {
return;
};
// Place the pill on the row below the last visible selection segment.
let Some(y) = y.checked_add(1).filter(|y| *y < area.bottom()) else {
return;
};
let key_label: Span<'static> = self.key_binding().into();
let key_label = key_label.content.as_ref().to_string();
let pill_text = format!(" ⧉ copy {key_label} ");
let pill_width = UnicodeWidthStr::width(pill_text.as_str());
if pill_width == 0 || area.width == 0 {
return;
}
let pill_width = (pill_width as u16).min(area.width);
// Prefer a small gap between the selected content and the pill so we don't visually merge
// into the highlighted selection block.
let desired_x = to_x.saturating_add(2);
let max_start_x = area.right().saturating_sub(pill_width);
let x = if max_start_x < area.x {
area.x
} else {
desired_x.clamp(area.x, max_start_x)
};
let pill_area = Rect::new(x, y, pill_width, 1);
let base_style = Style::new().bg(Color::DarkGray);
let icon_style = base_style.fg(Color::Cyan);
let bold_style = base_style.add_modifier(Modifier::BOLD);
let mut spans: Vec<Span<'static>> = vec![
Span::styled(" ", base_style),
Span::styled("⧉", icon_style),
Span::styled(" ", base_style),
Span::styled("copy", bold_style),
Span::styled(" ", base_style),
Span::styled(key_label, base_style),
];
spans.push(Span::styled(" ", base_style));
Paragraph::new(vec![Line::from(spans)]).render_ref(pill_area, buf);
self.affordance_rect = Some(pill_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Buffer;
fn buf_to_string(buf: &Buffer, area: Rect) -> String {
let mut s = String::new();
for y in area.y..area.bottom() {
for x in area.x..area.right() {
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
s.push('\n');
}
s
}
#[test]
fn ctrl_y_pill_does_not_include_ctrl_shift_c() {
let area = Rect::new(0, 0, 60, 3);
let mut buf = Buffer::empty(area);
for y in 0..area.height {
for x in 2..area.width.saturating_sub(1) {
buf[(x, y)].set_symbol("X");
}
}
let mut ui = TranscriptCopyUi::new_with_shortcut(CopySelectionShortcut::CtrlY);
ui.render_copy_pill(area, &mut buf, (1, 2), (1, 6), 0, 3);
let rendered = buf_to_string(&buf, area);
assert!(rendered.contains("copy"));
assert!(rendered.contains("ctrl + y"));
assert!(!rendered.contains("ctrl + shift + c"));
assert!(ui.affordance_rect.is_some());
}
}
File diff suppressed because it is too large. Load diff
+399
View File
@@ -0,0 +1,399 @@
//! Transcript rendering helpers (flattening, wrapping, and metadata).
//!
//! `App` treats the transcript (history cells) as the source of truth and
//! renders a *flattened* list of visual lines into the viewport. A single
//! history cell may render multiple visual lines, and the viewport may include
//! synthetic spacer rows between cells.
//!
//! This module centralizes the logic for:
//! - Flattening history cells into visual `ratatui::text::Line`s.
//! - Producing parallel metadata (`TranscriptLineMeta`) used for scroll
//! anchoring and "user row" styling.
//! - Computing *soft-wrap joiners* so copy can treat wrapped prose as one
//! logical line instead of inserting hard newlines.
use crate::history_cell::HistoryCell;
use crate::tui::scrolling::TranscriptLineMeta;
use ratatui::text::Line;
use std::sync::Arc;
/// Flattened transcript lines plus the metadata required to interpret them.
#[derive(Debug)]
pub(crate) struct TranscriptLines {
/// Flattened visual transcript lines, in the same order they are rendered.
pub(crate) lines: Vec<Line<'static>>,
/// Parallel metadata for each line (same length as `lines`).
///
/// This maps a visual line back to `(cell_index, line_in_cell)` so scroll
/// anchoring and "user row" styling remain stable across reflow.
pub(crate) meta: Vec<TranscriptLineMeta>,
/// Soft-wrap joiners (same length as `lines`).
///
/// `joiner_before[i]` is `Some(joiner)` when line `i` is a soft-wrap
/// continuation of line `i - 1`, and `None` when the break is a hard break
/// (between input lines/cells, or spacer rows).
///
/// Copy uses this to join wrapped prose without inserting hard newlines,
/// while still preserving hard line breaks and explicit blank lines.
pub(crate) joiner_before: Vec<Option<String>>,
}
/// Build flattened transcript lines without applying additional viewport wrapping.
///
/// This is useful for:
/// - Exit transcript rendering (ANSI) where we want the "cell as rendered"
/// output.
/// - Any consumer that wants a stable cell → line mapping without re-wrapping.
pub(crate) fn build_transcript_lines(
cells: &[Arc<dyn HistoryCell>],
width: u16,
) -> TranscriptLines {
// This function is the "lossless" transcript flattener:
// - it asks each cell for its transcript lines (including any per-cell prefixes/indents)
// - it inserts spacer rows between non-continuation cells to match the viewport layout
// - it emits parallel metadata so scroll anchoring can map visual lines back to cells.
let mut lines: Vec<Line<'static>> = Vec::new();
let mut meta: Vec<TranscriptLineMeta> = Vec::new();
let mut joiner_before: Vec<Option<String>> = Vec::new();
let mut has_emitted_lines = false;
for (cell_index, cell) in cells.iter().enumerate() {
// Cells provide joiners alongside lines so copy can distinguish hard breaks from soft wraps
// (and preserve the exact whitespace at wrap boundaries).
let rendered = cell.transcript_lines_with_joiners(width);
if rendered.lines.is_empty() {
continue;
}
// Cells that are not stream continuations are separated by an explicit spacer row.
// This keeps the flattened transcript aligned with what the user sees in the viewport
// and preserves intentional blank lines in copy.
if !cell.is_stream_continuation() {
if has_emitted_lines {
lines.push(Line::from(""));
meta.push(TranscriptLineMeta::Spacer);
joiner_before.push(None);
} else {
has_emitted_lines = true;
}
}
for (line_in_cell, line) in rendered.lines.into_iter().enumerate() {
// `line_in_cell` is the *visual* line index within the cell. Consumers use this for
// anchoring (e.g., "keep this row visible when the transcript reflows").
meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
});
lines.push(line);
// Maintain the `joiner_before` invariant: exactly one entry per output line.
joiner_before.push(
rendered
.joiner_before
.get(line_in_cell)
.cloned()
.unwrap_or(None),
);
}
}
TranscriptLines {
lines,
meta,
joiner_before,
}
}
/// Build flattened transcript lines as they appear in the transcript viewport.
///
/// This applies *viewport wrapping* to prose lines, while deliberately avoiding
/// wrapping for preformatted content (currently detected via the code-block
/// line style) so indentation remains meaningful for copy/paste.
pub(crate) fn build_wrapped_transcript_lines(
cells: &[Arc<dyn HistoryCell>],
width: u16,
) -> TranscriptLines {
use crate::render::line_utils::line_to_static;
use ratatui::style::Color;
if width == 0 {
return TranscriptLines {
lines: Vec::new(),
meta: Vec::new(),
joiner_before: Vec::new(),
};
}
let base_opts: crate::wrapping::RtOptions<'_> =
crate::wrapping::RtOptions::new(width.max(1) as usize);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut meta: Vec<TranscriptLineMeta> = Vec::new();
let mut joiner_before: Vec<Option<String>> = Vec::new();
let mut has_emitted_lines = false;
for (cell_index, cell) in cells.iter().enumerate() {
// Start from each cell's transcript view (prefixes/indents already applied), then apply
// viewport wrapping to prose while keeping preformatted content intact.
let rendered = cell.transcript_lines_with_joiners(width);
if rendered.lines.is_empty() {
continue;
}
if !cell.is_stream_continuation() {
if has_emitted_lines {
lines.push(Line::from(""));
meta.push(TranscriptLineMeta::Spacer);
joiner_before.push(None);
} else {
has_emitted_lines = true;
}
}
// `visual_line_in_cell` counts the output visual lines produced from this cell *after* any
// viewport wrapping. This is distinct from `base_idx` (the index into the cell's input
// lines), since a single input line may wrap into multiple visual lines.
let mut visual_line_in_cell: usize = 0;
let mut first = true;
for (base_idx, base_line) in rendered.lines.iter().enumerate() {
// Preserve code blocks (and other preformatted text) by not applying
// viewport wrapping, so indentation remains meaningful for copy/paste.
if base_line.style.fg == Some(Color::Cyan) {
lines.push(base_line.clone());
meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell: visual_line_in_cell,
});
visual_line_in_cell = visual_line_in_cell.saturating_add(1);
// Preformatted lines are treated as hard breaks; we keep the cell-provided joiner
// (which is typically `None`).
joiner_before.push(
rendered
.joiner_before
.get(base_idx)
.cloned()
.unwrap_or(None),
);
first = false;
continue;
}
let opts = if first {
base_opts.clone()
} else {
// For subsequent input lines within a cell, treat the "initial" indent as the
// cell's subsequent indent (matches textarea wrapping expectations).
base_opts
.clone()
.initial_indent(base_opts.subsequent_indent.clone())
};
// `word_wrap_line_with_joiners` returns both the wrapped visual lines and, for each
// continuation segment, the exact joiner substring that should be inserted instead of a
// newline when copying as a logical line.
let (wrapped, wrapped_joiners) =
crate::wrapping::word_wrap_line_with_joiners(base_line, opts);
for (seg_idx, (wrapped_line, seg_joiner)) in
wrapped.into_iter().zip(wrapped_joiners).enumerate()
{
lines.push(line_to_static(&wrapped_line));
meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell: visual_line_in_cell,
});
visual_line_in_cell = visual_line_in_cell.saturating_add(1);
if seg_idx == 0 {
// The first wrapped segment corresponds to the original input line, so we use
// the cell-provided joiner (hard break vs soft break *between input lines*).
joiner_before.push(
rendered
.joiner_before
.get(base_idx)
.cloned()
.unwrap_or(None),
);
} else {
// Subsequent wrapped segments are soft-wrap continuations produced by viewport
// wrapping, so we use the wrap-derived joiner.
joiner_before.push(seg_joiner);
}
}
first = false;
}
}
TranscriptLines {
lines,
meta,
joiner_before,
}
}
/// Render flattened transcript lines into ANSI strings suitable for printing after the TUI exits.
///
/// This helper mirrors the transcript viewport behavior:
/// - Merges line-level style into each span so ANSI output matches on-screen styling.
/// - For user-authored rows, pads the background style out to the full terminal width so prompts
/// appear as solid blocks in scrollback.
/// - Streams spans through the shared vt100 writer so downstream tests and tools see consistent
/// escape sequences.
pub(crate) fn render_lines_to_ansi(
lines: &[Line<'static>],
line_meta: &[TranscriptLineMeta],
is_user_cell: &[bool],
width: u16,
) -> Vec<String> {
use unicode_width::UnicodeWidthStr;
lines
.iter()
.enumerate()
.map(|(idx, line)| {
// Determine whether this visual line belongs to a user-authored cell. We use this to
// pad the background to the full terminal width so prompts appear as solid blocks in
// scrollback.
let is_user_row = line_meta
.get(idx)
.and_then(TranscriptLineMeta::cell_index)
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
.unwrap_or(false);
// Line-level styles in ratatui apply to the entire line, but spans can also have their
// own styles. ANSI output is span-based, so we "bake" the line style into every span by
// patching span style with the line style.
let mut merged_spans: Vec<ratatui::text::Span<'static>> = line
.spans
.iter()
.map(|span| ratatui::text::Span {
style: span.style.patch(line.style),
content: span.content.clone(),
})
.collect();
if is_user_row && width > 0 {
// For user rows, pad out to the full width so the background color extends across
// the line in terminal scrollback (mirrors the on-screen viewport behavior).
let text: String = merged_spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let text_width = UnicodeWidthStr::width(text.as_str());
let total_width = usize::from(width);
if text_width < total_width {
let pad_len = total_width.saturating_sub(text_width);
if pad_len > 0 {
let pad_style = crate::style::user_message_style();
merged_spans.push(ratatui::text::Span {
style: pad_style,
content: " ".repeat(pad_len).into(),
});
}
}
}
let mut buf: Vec<u8> = Vec::new();
let _ = crate::insert_history::write_spans(&mut buf, merged_spans.iter());
String::from_utf8(buf).unwrap_or_default()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history_cell::TranscriptLinesWithJoiners;
use pretty_assertions::assert_eq;
use std::sync::Arc;
#[derive(Debug)]
struct FakeCell {
lines: Vec<Line<'static>>,
joiner_before: Vec<Option<String>>,
is_stream_continuation: bool,
}
impl HistoryCell for FakeCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
fn transcript_lines_with_joiners(&self, _width: u16) -> TranscriptLinesWithJoiners {
TranscriptLinesWithJoiners {
lines: self.lines.clone(),
joiner_before: self.joiner_before.clone(),
}
}
fn is_stream_continuation(&self) -> bool {
self.is_stream_continuation
}
}
fn concat_line(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
}
#[test]
fn build_wrapped_transcript_lines_threads_joiners_and_spacers() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(FakeCell {
lines: vec![Line::from("• hello world")],
joiner_before: vec![None],
is_stream_continuation: false,
}),
Arc::new(FakeCell {
lines: vec![Line::from("• foo bar")],
joiner_before: vec![None],
is_stream_continuation: false,
}),
];
// Force wrapping so we get soft-wrap joiners for the second segment of each cell's line.
let transcript = build_wrapped_transcript_lines(&cells, 8);
assert_eq!(transcript.lines.len(), transcript.meta.len());
assert_eq!(transcript.lines.len(), transcript.joiner_before.len());
let rendered: Vec<String> = transcript.lines.iter().map(concat_line).collect();
assert_eq!(rendered, vec!["• hello", "world", "", "• foo", "bar"]);
assert_eq!(
transcript.meta,
vec![
TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 0
},
TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 1
},
TranscriptLineMeta::Spacer,
TranscriptLineMeta::CellLine {
cell_index: 1,
line_in_cell: 0
},
TranscriptLineMeta::CellLine {
cell_index: 1,
line_in_cell: 1
},
]
);
assert_eq!(
transcript.joiner_before,
vec![
None,
Some(" ".to_string()),
None,
None,
Some(" ".to_string()),
]
);
}
}
+49 -454
View File
@@ -1,96 +1,63 @@
//! Transcript selection helpers.
//! Transcript selection primitives.
//!
//! This module owns the inline transcript's selection model and helper
//! utilities:
//! The transcript (history) viewport is rendered as a flattened list of visual
//! lines after wrapping. Selection in the transcript needs to be stable across
//! scrolling and terminal resizes, so endpoints are expressed in
//! *content-relative* coordinates:
//!
//! - A **content-relative** selection representation ([`TranscriptSelection`])
//! expressed in terms of flattened, wrapped transcript line indices and
//! columns.
//! - A small mouse-driven **selection state machine** (`on_mouse_*` helpers)
//! that implements "start selection on drag" semantics.
//! - Copy extraction ([`selection_text`]) that matches on-screen glyph layout by
//! rendering selected lines into an offscreen [`ratatui::Buffer`].
//! - `line_index`: index into the flattened, wrapped transcript lines (visual
//! lines).
//! - `column`: a zero-based offset within that visual line, measured from the
//! first content column to the right of the gutter.
//!
//! ## Coordinate model
//! These coordinates are intentionally independent of the current viewport: the
//! user can scroll after selecting, and the selection should continue to refer
//! to the same conversation content.
//!
//! Selection endpoints are expressed in *wrapped* coordinates so they remain
//! stable across scrolling and reflowing when the terminal is resized:
//!
//! - `line_index` is an index into flattened wrapped transcript lines
//! (i.e. "visual lines").
//! - `column` is a 0-based column offset within that visual line, measured from
//! the first content column to the right of the transcript gutter.
//!
//! The transcript gutter is reserved for UI affordances (bullets, prefixes,
//! etc.). The gutter itself is not copyable; both selection highlighting and
//! copy extraction treat selection columns as starting at `base_x =
//! TRANSCRIPT_GUTTER_COLS`.
//! Clipboard reconstruction is implemented in `transcript_copy` (including
//! off-screen lines), while keybinding detection and the on-screen copy
//! affordance live in `transcript_copy_ui`.
//!
//! ## Mouse selection semantics
//!
//! The transcript supports click-and-drag selection for copying text. To avoid
//! distracting "1-cell selections" on a simple click, the selection highlight
//! only becomes active once the user drags:
//!
//! - `on_mouse_down`: stores an **anchor** point and clears any existing head.
//! - `on_mouse_drag`: sets the **head** point, creating an active selection
//! (`anchor` + `head`).
//! - `on_mouse_up`: clears the selection if it never became active (no head) or
//! if the drag ended at the anchor.
//!
//! The helper APIs return whether the selection state changed so callers can
//! schedule a redraw. `on_mouse_drag` also returns whether the caller should
//! lock the transcript scroll position when dragging while following the bottom
//! during streaming output.
//! The transcript supports click-and-drag selection. To avoid leaving a
//! distracting 1-cell highlight on a simple click, the selection only becomes
//! active once a drag updates the head point.
use crate::tui::scrolling::TranscriptScroll;
use itertools::Itertools as _;
use ratatui::prelude::*;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
/// Number of columns reserved for the transcript gutter before the copyable
/// transcript text begins.
/// Number of columns reserved for the transcript gutter (bullet/prefix space).
///
/// Transcript rendering prefixes each line with a short gutter (e.g. `• ` or
/// continuation padding). Selection coordinates intentionally exclude this
/// gutter so selection/copy operates on content columns instead of terminal
/// absolute columns.
pub(crate) const TRANSCRIPT_GUTTER_COLS: u16 = 2;
/// Content-relative selection within the inline transcript viewport.
///
/// Selection endpoints are expressed in terms of flattened, wrapped transcript
/// line indices and columns, so the highlight tracks logical conversation
/// content even when the viewport scrolls or the terminal is resized.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct TranscriptSelection {
/// The selection anchor (fixed start) in transcript coordinates.
/// The initial selection point (where the selection drag started).
///
/// This remains fixed while dragging; the highlighted region is the span
/// between `anchor` and `head`.
pub(crate) anchor: Option<TranscriptSelectionPoint>,
/// The selection head (moving end) in transcript coordinates.
/// The current selection point (where the selection drag currently ends).
///
/// This is `None` until the user drags, which prevents a simple click from
/// creating a persistent selection highlight.
pub(crate) head: Option<TranscriptSelectionPoint>,
}
impl TranscriptSelection {
/// Create an active selection with both endpoints set.
#[cfg(test)]
pub(crate) fn new(
anchor: impl Into<TranscriptSelectionPoint>,
head: impl Into<TranscriptSelectionPoint>,
) -> Self {
Self {
anchor: Some(anchor.into()),
head: Some(head.into()),
}
}
}
/// A single endpoint of a transcript selection.
///
/// `line_index` is an index into the flattened wrapped transcript lines, and
/// `column` is a zero-based column offset within that visual line, counted from
/// the first content column to the right of the transcript gutter.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct TranscriptSelectionPoint {
/// Index into the flattened wrapped transcript lines.
/// Index into the flattened, wrapped transcript lines.
pub(crate) line_index: usize,
/// Zero-based column offset within the wrapped line, relative to the first
/// content column to the right of the transcript gutter.
/// Zero-based content column (excluding the gutter).
///
/// This is not a terminal absolute column: callers add the gutter offset
/// when mapping it to a rendered buffer row.
pub(crate) column: u16,
}
@@ -102,12 +69,23 @@ impl TranscriptSelectionPoint {
}
impl From<(usize, u16)> for TranscriptSelectionPoint {
/// Convert from `(line_index, column)`.
fn from((line_index, column): (usize, u16)) -> Self {
Self::new(line_index, column)
}
}
/// Return `(start, end)` with `start <= end` in transcript order.
pub(crate) fn ordered_endpoints(
anchor: TranscriptSelectionPoint,
head: TranscriptSelectionPoint,
) -> (TranscriptSelectionPoint, TranscriptSelectionPoint) {
if anchor <= head {
(anchor, head)
} else {
(head, anchor)
}
}
/// Begin a potential transcript selection (left button down).
///
/// This records an anchor point and clears any existing head. The selection is
@@ -235,394 +213,11 @@ fn end(selection: &mut TranscriptSelection) {
}
}
/// Extract the full transcript selection as plain text.
///
/// This intentionally does *not* use viewport state. Instead it:
///
/// - Applies the same word-wrapping used for on-screen rendering, producing
/// flattened "visual" lines.
/// - Renders each selected visual line into a 1-row offscreen `Buffer` and
/// extracts the selected character cells from that buffer.
///
/// Using the rendered buffer (instead of slicing the source strings) keeps copy
/// semantics aligned with what the user sees on screen, including:
///
/// - Prefixes / indentation introduced during rendering (e.g. list markers).
/// - The transcript gutter: selection columns are defined relative to the
/// first content column to the right of the gutter (`base_x =
/// TRANSCRIPT_GUTTER_COLS`).
/// - Multi-cell glyph rendering decisions made by the backend.
///
/// Notes:
///
/// - Trailing padding to the right margin is not included; we clamp each line
/// to the last non-space glyph to avoid copying a full-width block of spaces.
/// - `TranscriptSelectionPoint::column` can be arbitrarily large (e.g.
/// `u16::MAX` when dragging to the right edge); we clamp to the rendered line
/// width so "copy to end of line" behaves naturally.
pub(crate) fn selection_text(
lines: &[Line<'static>],
selection: TranscriptSelection,
width: u16,
) -> Option<String> {
let (anchor, head) = selection.anchor.zip(selection.head)?;
if anchor == head {
return None;
}
let (start, end) = ordered_endpoints(anchor, head);
let wrapped = wrap_transcript_lines(lines, width)?;
let ctx = RenderContext::new(width)?;
let total_lines = wrapped.len();
if start.line_index >= total_lines {
return None;
}
// If the selection ends beyond the last wrapped line, clamp it so selection
// behaves like "copy through the end" rather than returning no text.
let (end_line_index, end_is_clamped) = clamp_end_line(end.line_index, total_lines)?;
let mut buf = Buffer::empty(ctx.area);
let mut lines_out: Vec<String> = Vec::new();
for (line_index, line) in wrapped
.iter()
.enumerate()
.take(end_line_index + 1)
.skip(start.line_index)
{
buf.reset();
line.render_ref(ctx.area, &mut buf);
let Some((row_sel_start, row_sel_end)) =
ctx.selection_bounds_for_line(line_index, start, end, end_is_clamped)
else {
// Preserve row count/newlines within the selection even if this
// particular visual line produces no selected cells.
lines_out.push(String::new());
continue;
};
let Some(content_end_x) = ctx.content_end_x(&buf) else {
// Preserve explicit blank lines (e.g., spacer rows) in the selection.
lines_out.push(String::new());
continue;
};
let from_x = row_sel_start.max(ctx.base_x);
let to_x = row_sel_end.min(content_end_x);
if from_x > to_x {
// Preserve row count/newlines even when selection falls beyond the
// rendered content for this visual line.
lines_out.push(String::new());
continue;
}
lines_out.push(ctx.extract_text(&buf, from_x, to_x));
}
Some(lines_out.join("\n"))
}
/// Return `(start, end)` with `start <= end` in transcript order.
pub(crate) fn ordered_endpoints(
anchor: TranscriptSelectionPoint,
head: TranscriptSelectionPoint,
) -> (TranscriptSelectionPoint, TranscriptSelectionPoint) {
if anchor <= head {
(anchor, head)
} else {
(head, anchor)
}
}
/// Wrap transcript lines using the same algorithm as on-screen rendering.
///
/// Returns `None` for invalid widths or when wrapping produces no visual lines.
fn wrap_transcript_lines<'a>(lines: &'a [Line<'static>], width: u16) -> Option<Vec<Line<'a>>> {
if width == 0 || lines.is_empty() {
return None;
}
let wrapped = crate::wrapping::word_wrap_lines_borrowed(lines, width.max(1) as usize);
(!wrapped.is_empty()).then_some(wrapped)
}
/// Context for rendering a single wrapped transcript line into a 1-row buffer and
/// extracting selected cells.
#[derive(Debug, Clone, Copy)]
struct RenderContext {
/// One-row region used for offscreen rendering.
area: Rect,
/// X coordinate where copyable transcript content begins (gutter skipped).
base_x: u16,
/// Maximum X coordinate inside the render area (inclusive).
max_x: u16,
/// Maximum content-relative column (0-based) within the render area.
max_content_col: u16,
}
impl RenderContext {
/// Create a 1-row render context for a given terminal width.
///
/// Returns `None` when the width is too small to hold any copyable content
/// (e.g. the gutter consumes the entire row).
fn new(width: u16) -> Option<Self> {
if width == 0 {
return None;
}
let area = Rect::new(0, 0, width, 1);
let base_x = area.x.saturating_add(TRANSCRIPT_GUTTER_COLS);
let max_x = area.right().saturating_sub(1);
if base_x > max_x {
return None;
}
Some(Self {
area,
base_x,
max_x,
max_content_col: max_x.saturating_sub(base_x),
})
}
/// Compute the inclusive selection X range for this visual line.
///
/// `start`/`end` columns are content-relative (0 starts at the first column
/// to the right of the transcript gutter). For the terminal line containing
/// the selection endpoint, this clamps the selection to that endpoint; for
/// intermediate lines it selects the whole line.
///
/// If the selection end was clamped to the last available line (meaning the
/// logical selection extended beyond the rendered transcript), the final
/// line is treated as selecting through the end of that line.
fn selection_bounds_for_line(
&self,
line_index: usize,
start: TranscriptSelectionPoint,
end: TranscriptSelectionPoint,
end_is_clamped: bool,
) -> Option<(u16, u16)> {
let line_start_col = if line_index == start.line_index {
start.column
} else {
0
};
let line_end_col = if !end_is_clamped && line_index == end.line_index {
end.column
} else {
self.max_content_col
};
let row_sel_start = self.base_x.saturating_add(line_start_col);
let row_sel_end = self.base_x.saturating_add(line_end_col).min(self.max_x);
(row_sel_start <= row_sel_end).then_some((row_sel_start, row_sel_end))
}
/// Find the last non-space glyph in the rendered content area.
///
/// This is used to avoid copying right-margin padding when the rendered row
/// is shorter than the terminal width.
fn content_end_x(&self, buf: &Buffer) -> Option<u16> {
(self.base_x..=self.max_x)
.rev()
.find(|&x| buf[(x, 0)].symbol() != " ")
}
/// Extract rendered cell contents from an inclusive `[from_x, to_x]` range.
///
/// Note: terminals represent wide glyphs (e.g. CJK characters) using multiple
/// cells, but only the first cell contains the glyph's symbol. The remaining
/// cells are "continuation" cells that should not be copied as separate
/// characters. Ratatui marks those continuation cells as a single space in
/// the buffer, so we must explicitly skip `width - 1` following cells after
/// reading each rendered symbol to avoid producing output like `"コ X"`.
fn extract_text(&self, buf: &Buffer, from_x: u16, to_x: u16) -> String {
(from_x..=to_x)
.batching(|xs| {
let x = xs.next()?;
let symbol = buf[(x, 0)].symbol();
for _ in 0..symbol.width().saturating_sub(1) {
xs.next();
}
(!symbol.is_empty()).then_some(symbol)
})
.join("")
}
}
/// Clamp `end_line_index` to the last available line and report if it was clamped.
///
/// Returns `None` when there are no wrapped lines.
fn clamp_end_line(end_line_index: usize, total_lines: usize) -> Option<(usize, bool)> {
if total_lines == 0 {
return None;
}
let clamped = end_line_index.min(total_lines.saturating_sub(1));
Some((clamped, clamped != end_line_index))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn selection_text_returns_none_when_missing_endpoints() {
let lines = vec![Line::from(vec!["• ".into(), "Hello".into()])];
let selection = TranscriptSelection::default();
assert_eq!(selection_text(&lines, selection, 40), None);
}
#[test]
fn selection_text_returns_none_when_endpoints_equal() {
let lines = vec![Line::from(vec!["• ".into(), "Hello".into()])];
let selection = TranscriptSelection::new((0, 2), (0, 2));
assert_eq!(selection_text(&lines, selection, 40), None);
}
#[test]
fn selection_text_returns_none_for_empty_lines() {
let selection = TranscriptSelection::new((0, 0), (0, 1));
assert_eq!(selection_text(&[], selection, 40), None);
}
#[test]
fn selection_text_returns_none_for_zero_width() {
let lines = vec![Line::from(vec!["• ".into(), "Hello".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 1));
assert_eq!(selection_text(&lines, selection, 0), None);
}
#[test]
fn selection_text_returns_none_when_width_smaller_than_gutter() {
let lines = vec![Line::from(vec!["• ".into(), "Hello".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 1));
assert_eq!(selection_text(&lines, selection, 2), None);
}
#[test]
fn selection_text_skips_gutter_prefix() {
let lines = vec![Line::from(vec!["• ".into(), "Hello".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 4));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "Hello");
}
#[test]
fn selection_text_selects_substring_single_line() {
let lines = vec![Line::from(vec!["• ".into(), "Hello world".into()])];
let selection = TranscriptSelection::new((0, 6), (0, 10));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "world");
}
#[test]
fn selection_text_preserves_interior_spaces() {
let lines = vec![Line::from(vec!["• ".into(), "a b".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 3));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "a b");
}
#[test]
fn selection_text_skips_hidden_wide_glyph_cells() {
let lines = vec![Line::from(vec!["• ".into(), "コX".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 2));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "コX");
}
#[test]
fn selection_text_orders_reversed_endpoints() {
let lines = vec![Line::from(vec!["• ".into(), "Hello world".into()])];
let selection = TranscriptSelection::new((0, 10), (0, 6));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "world");
}
#[test]
fn selection_text_selects_multiple_lines_with_partial_endpoints() {
let lines = vec![
Line::from(vec!["• ".into(), "abcde".into()]),
Line::from(vec!["• ".into(), "fghij".into()]),
Line::from(vec!["• ".into(), "klmno".into()]),
];
let selection = TranscriptSelection::new((0, 2), (2, 2));
assert_eq!(
selection_text(&lines, selection, 40).unwrap(),
"cde\nfghij\nklm"
);
}
#[test]
fn selection_text_selects_to_end_of_line_for_large_column() {
let lines = vec![Line::from(vec!["• ".into(), "one".into()])];
let selection = TranscriptSelection::new((0, 0), (0, u16::MAX));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one");
}
#[test]
fn selection_text_includes_indentation_spaces() {
let lines = vec![Line::from(vec!["• ".into(), " ind".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 4));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), " ind");
}
#[test]
fn selection_text_preserves_empty_lines() {
let lines = vec![
Line::from(vec!["• ".into(), "one".into()]),
Line::from("• "),
Line::from(vec!["• ".into(), "two".into()]),
];
let selection = TranscriptSelection::new((0, 0), (2, 2));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one\n\ntwo");
}
#[test]
fn selection_text_clamps_end_line_index() {
let lines = vec![
Line::from(vec!["• ".into(), "one".into()]),
Line::from(vec!["• ".into(), "two".into()]),
];
let selection = TranscriptSelection::new((0, 0), (100, u16::MAX));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one\ntwo");
}
#[test]
fn selection_text_clamps_end_line_index_ignoring_end_column() {
let lines = vec![
Line::from(vec!["• ".into(), "one".into()]),
Line::from(vec!["• ".into(), "two".into()]),
];
let selection = TranscriptSelection::new((0, 0), (100, 0));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one\ntwo");
}
#[test]
fn selection_text_returns_none_when_start_line_out_of_range() {
let lines = vec![Line::from(vec!["• ".into(), "one".into()])];
let selection = TranscriptSelection::new((100, 0), (101, 0));
assert_eq!(selection_text(&lines, selection, 40), None);
}
#[test]
fn selection_only_highlights_on_drag() {
let anchor = TranscriptSelectionPoint::new(0, 1);
+184 -32
View File
@@ -155,6 +155,13 @@ pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) ->
where
O: Into<RtOptions<'a>>,
{
let (lines, _joiners) = word_wrap_line_with_joiners(line, width_or_options);
lines
}
fn flatten_line_and_bounds<'a>(
line: &'a Line<'a>,
) -> (String, Vec<(Range<usize>, ratatui::style::Style)>) {
// Flatten the line and record span byte ranges.
let mut flat = String::new();
let mut span_bounds = Vec::new();
@@ -166,6 +173,43 @@ where
acc += text.len();
span_bounds.push((start..acc, s.style));
}
(flat, span_bounds)
}
fn build_wrapped_line_from_range<'a>(
indent: Line<'a>,
original: &'a Line<'a>,
span_bounds: &[(Range<usize>, ratatui::style::Style)],
range: &Range<usize>,
) -> Line<'a> {
let mut out = indent.style(original.style);
let sliced = slice_line_spans(original, span_bounds, range);
let mut spans = out.spans;
spans.append(
&mut sliced
.spans
.into_iter()
.map(|s| s.patch_style(original.style))
.collect(),
);
out.spans = spans;
out
}
/// Wrap a single line and also return, for each output line, the string that should be inserted
/// when joining it to the previous output line as a *soft wrap*.
///
/// - The first output line always has `None`.
/// - Continuation lines have `Some(joiner)` where `joiner` is the exact substring (often spaces,
/// possibly empty) that was skipped at the wrap boundary.
pub(crate) fn word_wrap_line_with_joiners<'a, O>(
line: &'a Line<'a>,
width_or_options: O,
) -> (Vec<Line<'a>>, Vec<Option<String>>)
where
O: Into<RtOptions<'a>>,
{
let (flat, span_bounds) = flatten_line_and_bounds(line);
let rt_opts: RtOptions<'a> = width_or_options.into();
let opts = Options::new(rt_opts.width)
@@ -176,7 +220,9 @@ where
.word_splitter(rt_opts.word_splitter);
let mut out: Vec<Line<'a>> = Vec::new();
let mut joiners: Vec<Option<String>> = Vec::new();
// The first output line uses the initial indent and a reduced available width.
// Compute first line range with reduced width due to initial indent.
let initial_width_available = opts
.width
@@ -184,54 +230,100 @@ where
.max(1);
let initial_wrapped = wrap_ranges_trim(&flat, opts.clone().width(initial_width_available));
let Some(first_line_range) = initial_wrapped.first() else {
return vec![rt_opts.initial_indent.clone()];
out.push(rt_opts.initial_indent.clone());
joiners.push(None);
return (out, joiners);
};
// Build first wrapped line with initial indent.
let mut first_line = rt_opts.initial_indent.clone().style(line.style);
{
let sliced = slice_line_spans(line, &span_bounds, first_line_range);
let mut spans = first_line.spans;
spans.append(
&mut sliced
.spans
.into_iter()
.map(|s| s.patch_style(line.style))
.collect(),
);
first_line.spans = spans;
out.push(first_line);
}
let first_line = build_wrapped_line_from_range(
rt_opts.initial_indent.clone(),
line,
&span_bounds,
first_line_range,
);
out.push(first_line);
joiners.push(None);
// Wrap the remainder using subsequent indent width and map back to original indices.
let base = first_line_range.end;
// Wrap the remainder using subsequent indent width. We also compute the joiner strings that
// were skipped at each wrap boundary so callers can treat these as soft wraps during copy.
let mut base = first_line_range.end;
let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
let base = base + skip_leading_spaces;
let joiner_first = flat[base..base.saturating_add(skip_leading_spaces)].to_string();
base = base.saturating_add(skip_leading_spaces);
let subsequent_width_available = opts
.width
.saturating_sub(rt_opts.subsequent_indent.width())
.max(1);
let remaining_wrapped = wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available));
for r in &remaining_wrapped {
let remaining = &flat[base..];
let remaining_wrapped = wrap_ranges_trim(remaining, opts.width(subsequent_width_available));
let mut prev_end = 0usize;
for (i, r) in remaining_wrapped.iter().enumerate() {
if r.is_empty() {
continue;
}
let mut subsequent_line = rt_opts.subsequent_indent.clone().style(line.style);
// Each continuation line has `Some(joiner)`. The joiner may be empty (e.g. splitting a
// long word), but the distinction from `None` is important: `None` represents a hard break.
let joiner = if i == 0 {
joiner_first.clone()
} else {
remaining[prev_end..r.start].to_string()
};
prev_end = r.end;
let offset_range = (r.start + base)..(r.end + base);
let sliced = slice_line_spans(line, &span_bounds, &offset_range);
let mut spans = subsequent_line.spans;
spans.append(
&mut sliced
.spans
.into_iter()
.map(|s| s.patch_style(line.style))
.collect(),
let subsequent_line = build_wrapped_line_from_range(
rt_opts.subsequent_indent.clone(),
line,
&span_bounds,
&offset_range,
);
subsequent_line.spans = spans;
out.push(subsequent_line);
joiners.push(Some(joiner));
}
out
(out, joiners)
}
/// Like `word_wrap_lines`, but also returns a parallel vector of soft-wrap joiners.
///
/// The joiner is `None` when the line break is a hard break (between input lines), and `Some`
/// when the line break is a soft wrap continuation produced by the wrapping algorithm.
#[allow(private_bounds)] // IntoLineInput isn't public, but it doesn't really need to be.
pub(crate) fn word_wrap_lines_with_joiners<'a, I, O, L>(
lines: I,
width_or_options: O,
) -> (Vec<Line<'static>>, Vec<Option<String>>)
where
I: IntoIterator<Item = L>,
L: IntoLineInput<'a>,
O: Into<RtOptions<'a>>,
{
let base_opts: RtOptions<'a> = width_or_options.into();
let mut out: Vec<Line<'static>> = Vec::new();
let mut joiners: Vec<Option<String>> = Vec::new();
for (idx, line) in lines.into_iter().enumerate() {
let line_input = line.into_line_input();
let opts = if idx == 0 {
base_opts.clone()
} else {
let mut o = base_opts.clone();
let sub = o.subsequent_indent.clone();
o = o.initial_indent(sub);
o
};
let (wrapped, wrapped_joiners) = word_wrap_line_with_joiners(line_input.as_ref(), opts);
for (l, j) in wrapped.into_iter().zip(wrapped_joiners) {
out.push(crate::render::line_utils::line_to_static(&l));
joiners.push(j);
}
}
(out, joiners)
}
/// Utilities to allow wrapping either borrowed or owned lines.
@@ -552,6 +644,66 @@ mod tests {
assert_eq!(concat_line(&out[1]), "cd");
}
#[test]
fn wrap_line_with_joiners_matches_word_wrap_line_output() {
let opts = RtOptions::new(8)
.initial_indent(Line::from("- "))
.subsequent_indent(Line::from(" "));
let line = Line::from(vec!["hello ".red(), "world".into()]);
let out = word_wrap_line(&line, opts.clone());
let (with_joiners, joiners) = word_wrap_line_with_joiners(&line, opts);
assert_eq!(
with_joiners.iter().map(concat_line).collect_vec(),
out.iter().map(concat_line).collect_vec()
);
assert_eq!(joiners.len(), with_joiners.len());
assert_eq!(
joiners.first().cloned().unwrap_or(Some("x".to_string())),
None
);
}
#[test]
fn wrap_line_with_joiners_includes_skipped_spaces() {
let line = Line::from("hello world");
let (wrapped, joiners) = word_wrap_line_with_joiners(&line, 8);
assert_eq!(
wrapped.iter().map(concat_line).collect_vec(),
vec!["hello", "world"]
);
assert_eq!(joiners, vec![None, Some(" ".to_string())]);
}
#[test]
fn wrap_line_with_joiners_uses_empty_joiner_for_mid_word_split() {
let line = Line::from("abcd");
let (wrapped, joiners) = word_wrap_line_with_joiners(&line, 2);
assert_eq!(
wrapped.iter().map(concat_line).collect_vec(),
vec!["ab", "cd"]
);
assert_eq!(joiners, vec![None, Some("".to_string())]);
}
#[test]
fn wrap_lines_with_joiners_marks_hard_breaks_between_input_lines() {
let (wrapped, joiners) =
word_wrap_lines_with_joiners([Line::from("hello world"), Line::from("foo bar")], 5);
assert_eq!(
wrapped.iter().map(concat_line).collect_vec(),
vec!["hello", "world", "foo", "bar"]
);
assert_eq!(
joiners,
vec![None, Some(" ".to_string()), None, Some(" ".to_string())]
);
}
#[test]
fn wrap_lines_applies_initial_indent_only_once() {
let opts = RtOptions::new(8)