Compare commits

...

1 Commits

Author SHA1 Message Date
Josh McKinney
c8901b3784 feat(tui2): improve transcript copy fidelity
Improve clipboard output for transcript selections, even when content is wrapped.

- Preserve meaningful indentation for code blocks.
- Treat soft-wrapped prose as a single logical line via wrap joiners.
- Emit Markdown source markers when copying (inline backticks, code fences).
- Extract selection/copy reconstruction into `transcript_copy`.
- Thread joiner metadata through history rendering and wrapping helpers.
- Update docs and add focused test coverage.
2025-12-22 23:09:42 -08:00
12 changed files with 2013 additions and 1018 deletions

View File

@@ -183,13 +183,18 @@ Mouse interaction is a firstclass part of the new design:
that we use for bullets/prefixes.
- **Copy.**
- When the user triggers copy, the TUI reconstructs the same wrapped transcript lines used for
on-screen rendering.
- It then walks the content-relative selection range (even if the selection extends outside the
current viewport) and re-renders each selected visual line into a 1-row offscreen buffer to
reconstruct the exact text region the user highlighted (including internal spaces and empty
lines, while skipping wide-glyph continuation cells and right-margin padding).
- That text is sent to the system clipboard and a status footer indicates success or failure.
- When the user triggers copy, the TUI reconstructs the wrapped transcript lines using the same
flattening/wrapping rules as the visible view.
- It then reconstructs a highfidelity clipboard string from the selected logical lines:
- Preserves meaningful indentation (especially for code blocks).
- Treats soft-wrapped prose as a single logical line by joining wrap continuations instead of
inserting hard newlines.
- Emits Markdown source markers (e.g. backticks and fences) for copy/paste, even if the UI
chooses to render those constructs without showing the literal markers.
- Copy operates on the full selection range, even if the selection extends outside the current
viewport.
- The resulting text is sent to the system clipboard and a status footer indicates success or
failure.
Because scrolling, selection, and copy all operate on the same flattened transcript representation,
they remain consistent even as the viewport resizes or the chat composer grows/shrinks. Owning our

View File

@@ -17,7 +17,7 @@ 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_selection::TRANSCRIPT_GUTTER_COLS;
use crate::transcript_selection::TranscriptSelection;
use crate::transcript_selection::TranscriptSelectionPoint;
@@ -31,9 +31,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 +77,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;
@@ -486,7 +482,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(),
@@ -570,13 +566,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 +709,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 +720,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 +777,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 +791,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);
@@ -1125,7 +1098,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 +1124,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 +1151,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 +1273,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 +2061,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;
@@ -2363,10 +2235,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 +2640,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"));
}

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;
#[derive(Debug, Clone)]
/// 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.
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 {

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,37 @@ mod tests {
);
}
#[test]
#[allow(clippy::disallowed_methods)]
fn write_spans_emits_truecolor_and_indexed_sgr() {
let spans = [Span::styled(
"X",
Style::default()
.fg(Color::Rgb(1, 2, 3))
.bg(Color::Indexed(42)),
)];
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 +364,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 +402,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");

View File

@@ -78,6 +78,8 @@ mod terminal_palette;
mod text_formatting;
mod tooltips;
mod transcript_copy;
mod transcript_copy_ui;
mod transcript_render;
mod transcript_selection;
mod tui;
mod ui_consts;

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);

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

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());
}
}

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()),
]
);
}
}

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);

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)