diff --git a/codex-rs/tui2/docs/tui_viewport_and_history.md b/codex-rs/tui2/docs/tui_viewport_and_history.md index 57e697861d9..50d23ffa142 100644 --- a/codex-rs/tui2/docs/tui_viewport_and_history.md +++ b/codex-rs/tui2/docs/tui_viewport_and_history.md @@ -183,13 +183,18 @@ Mouse interaction is a first‑class 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 high‑fidelity 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 diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 86f2017bda6..732a15d345f 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -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 = app .transcript_cells .iter() .map(|cell| cell.as_any().is::()) .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 = cells .iter() .map(|c| c.as_any().is::()) .collect(); - let base_opts: RtOptions<'_> = RtOptions::new(transcript_area.width.max(1) as usize); - let mut wrapped_is_user_row: Vec = 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], - width: u16, - ) -> (Vec>, Vec) { - let mut lines: Vec> = Vec::new(); - let mut line_meta: Vec = 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 { - 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> = 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 = 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")); } diff --git a/codex-rs/tui2/src/history_cell.rs b/codex-rs/tui2/src/history_cell.rs index f9fd36b92eb..b9b4b745f0c 100644 --- a/codex-rs/tui2/src/history_cell.rs +++ b/codex-rs/tui2/src/history_cell.rs @@ -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>, + /// For each output line, whether and how to join it to the previous line when copying. + pub(crate) joiner_before: Vec>, +} + /// Represents an event to display in the conversation history. Returns its /// `Vec>` 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> { - let mut lines: Vec> = 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> = Vec::new(); + let mut joins: Vec> = 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> { + self.lines_with_joiners(width).lines + } + + fn lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners { let mut lines: Vec> = Vec::new(); append_markdown( &self.content, @@ -195,12 +268,17 @@ impl ReasoningSummaryCell { }) .collect::>(); - 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> { - 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> = Vec::new(); + let mut joiner_before: Vec> = 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,20 +474,29 @@ impl PrefixedWrappedHistoryCell { impl HistoryCell for PrefixedWrappedHistoryCell { fn display_lines(&self, width: u16) -> Vec> { + 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 Vec::new(); + 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 wrapped = word_wrap_lines(&self.text, opts); - let mut out = Vec::new(); - push_owned_lines(&wrapped, &mut out); - out - } - - fn desired_height(&self, width: u16) -> u16 { - self.display_lines(width).len() as u16 + let (lines, joiner_before) = + crate::wrapping::word_wrap_lines_with_joiners(&self.text, opts); + TranscriptLinesWithJoiners { + lines, + joiner_before, + } } } diff --git a/codex-rs/tui2/src/insert_history.rs b/codex-rs/tui2/src/insert_history.rs index e0e2731678a..db22acef98f 100644 --- a/codex-rs/tui2/src/insert_history.rs +++ b/codex-rs/tui2/src/insert_history.rs @@ -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`) 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>, { - 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 = Vec::new(); + write_spans(&mut actual, spans.iter()).unwrap(); + + let mut expected: Vec = 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"); diff --git a/codex-rs/tui2/src/lib.rs b/codex-rs/tui2/src/lib.rs index e006ed8a725..a2fa8fb3498 100644 --- a/codex-rs/tui2/src/lib.rs +++ b/codex-rs/tui2/src/lib.rs @@ -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; diff --git a/codex-rs/tui2/src/markdown_render.rs b/codex-rs/tui2/src/markdown_render.rs index 4ba7b613167..22a234326aa 100644 --- a/codex-rs/tui2/src/markdown_render.rs +++ b/codex-rs/tui2/src/markdown_render.rs @@ -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); diff --git a/codex-rs/tui2/src/markdown_render_tests.rs b/codex-rs/tui2/src/markdown_render_tests.rs index c5496996957..fc9c8bc8033 100644 --- a/codex-rs/tui2/src/markdown_render_tests.rs +++ b/codex-rs/tui2/src/markdown_render_tests.rs @@ -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 = text - .lines - .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.clone()) - .collect::() - }) - .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 = text - .lines - .iter() - .map(|l| { - l.spans - .iter() - .map(|s| s.content.clone()) - .collect::() - }) - .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); } diff --git a/codex-rs/tui2/src/transcript_copy.rs b/codex-rs/tui2/src/transcript_copy.rs index b7242ee29f1..141ce901f74 100644 --- a/codex-rs/tui2/src/transcript_copy.rs +++ b/codex-rs/tui2/src/transcript_copy.rs @@ -1,329 +1,801 @@ -//! Transcript-selection copy UX helpers. +//! Converting a transcript selection to clipboard text. //! -//! # Background +//! Copy is driven by a content-relative selection (`TranscriptSelectionPoint`), +//! but the transcript is rendered with styling and wrapping for the TUI. This +//! module reconstructs clipboard text from the rendered transcript lines while +//! preserving user expectations: //! -//! 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: +//! - Soft-wrapped prose is treated as a single logical line when copying. +//! - Code blocks preserve meaningful indentation. +//! - Markdown “source markers” are emitted when copying (backticks for inline +//! code, triple-backtick fences for code blocks) even if the on-screen +//! rendering is styled differently. //! -//! - 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. +//! ## Inputs and invariants //! -//! This module centralizes: +//! Clipboard reconstruction is performed over the same *visual lines* that are +//! rendered in the transcript viewport: //! -//! - 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. +//! - `lines`: wrapped transcript `Line`s, including the gutter spans. +//! - `joiner_before`: a parallel vector describing which wrapped lines are +//! *soft wrap* continuations (and what to insert at the wrap boundary). +//! - `(line_index, column)` selection points in *content space* (columns exclude +//! the gutter). //! -//! # VS Code shortcut rationale +//! Callers must keep `lines` and `joiner_before` aligned. In practice, `App` +//! obtains both from `transcript_render`, which itself builds from each cell's +//! `HistoryCell::transcript_lines_with_joiners` implementation. //! -//! 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. - -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; +//! ## Style-derived Markdown cues +//! +//! For fidelity, we copy Markdown source markers even though the viewport may +//! render content using styles instead of literal characters. Today, the copy +//! logic derives "inline code" and "code block" boundaries from the styling we +//! apply during rendering (currently cyan spans/lines). +//! +//! If transcript styling changes (for example, if code blocks stop using cyan), +//! update `is_code_block_line` and [`span_is_inline_code`] so clipboard output +//! continues to match user expectations. +//! +//! The caller can choose whether copy covers only the visible viewport range +//! (by passing `visible_start..visible_end`) or spans the entire transcript +//! (by passing `0..lines.len()`). +//! +//! UI affordances (keybinding detection and the on-screen "copy" pill) live in +//! `transcript_copy_ui`. + 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::history_cell::HistoryCell; use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS; +use crate::transcript_selection::TranscriptSelection; +use crate::transcript_selection::TranscriptSelectionPoint; +use std::sync::Arc; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -/// The shortcut we advertise and accept for "copy selection". -pub(crate) enum CopySelectionShortcut { - CtrlShiftC, - CtrlY, +/// Render the current transcript selection into clipboard text. +/// +/// This is the `App`-level helper: it rebuilds wrapped transcript lines using +/// the same rules as the on-screen viewport and then applies +/// [`selection_to_copy_text`] across the full transcript range (including +/// off-screen lines). +pub(crate) fn selection_to_copy_text_for_cells( + cells: &[Arc], + selection: TranscriptSelection, + width: u16, +) -> Option { + let (anchor, head) = selection.anchor.zip(selection.head)?; + + let transcript = crate::transcript_render::build_wrapped_transcript_lines(cells, width); + let total_lines = transcript.lines.len(); + if total_lines == 0 { + return None; + } + + selection_to_copy_text( + &transcript.lines, + &transcript.joiner_before, + anchor, + head, + 0, + total_lines, + width, + ) } -/// Returns the best shortcut to advertise/accept for "copy selection". +/// Render the selected region into clipboard text. +/// +/// `lines` must be the wrapped transcript lines as rendered by the TUI, +/// including the leading gutter spans. `start`/`end` columns are expressed in +/// content-space (excluding the gutter), and will be ordered internally if the +/// endpoints are reversed. +/// +/// `joiner_before[i]` is the exact string to insert *before* `lines[i]` when +/// it is a continuation of a soft-wrapped prose line. This enables copy to +/// treat soft-wrapped prose as a single logical line. +/// +/// Notes: /// -/// 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. +/// - For code/preformatted runs, copy is permitted to extend beyond the +/// viewport width when the user selects “to the right edge”, so we avoid +/// producing truncated logical lines in narrow terminals. +/// - Markdown markers are derived from render-time styles (see module docs). +/// - Column math is display-width-aware (wide glyphs count as multiple columns). /// -/// 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; +/// Returns `None` if the inputs imply an empty selection or if `width` is too +/// small to contain the gutter plus at least one content column. +pub(crate) fn selection_to_copy_text( + lines: &[Line<'static>], + joiner_before: &[Option], + start: TranscriptSelectionPoint, + end: TranscriptSelectionPoint, + visible_start: usize, + visible_end: usize, + width: u16, +) -> Option { + use ratatui::style::Color; + + if width <= TRANSCRIPT_GUTTER_COLS { + return None; } - 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')), + // Selection points are expressed in content-relative coordinates and may be provided in either + // direction (dragging "backwards"). Normalize to a forward `(start, end)` pair so the rest of + // the logic can assume `start <= end`. + let (start, end) = order_points(start, end); + if start == end { + return None; + } + + // Transcript `Line`s include a left gutter (bullet/prefix space). Selection columns exclude the + // gutter, so we translate selection columns to absolute columns by adding `base_x`. + let base_x = TRANSCRIPT_GUTTER_COLS; + let max_x = width.saturating_sub(1); + + let mut out = String::new(); + let mut prev_selected_line: Option = None; + + // We emit Markdown fences around runs of code/preformatted visual lines so: + // - the clipboard captures source-style markers (` ``` `) even if the viewport is stylized + // - indentation is preserved and paste is stable in editors + let mut in_code_run = false; + + // `wrote_any` lets us handle separators (newline or soft-wrap joiner) without special-casing + // "first output line" at every decision point. + let mut wrote_any = false; + + for line_index in visible_start..visible_end { + // Only consider lines that intersect the selection's line range. (Selection endpoints are + // clamped elsewhere; if the indices don't exist, `lines.get(...)` returns `None`.) + if line_index < start.line_index || line_index > end.line_index { + continue; + } + + let line = lines.get(line_index)?; + + // Code blocks (and other preformatted content) are detected via styling and copied as + // "verbatim lines" (no inline Markdown re-encoding). This also enables special handling for + // narrow terminals: selecting "to the right edge" should copy the full logical line, not a + // viewport-truncated slice. + let is_code_block_line = line.style.fg == Some(Color::Cyan); + + // Flatten the line to compute the rightmost non-space column. We use that to: + // - avoid copying trailing right-margin padding + // - clamp prose selection to the viewport width + let flat = line_to_flat(line); + let text_end = if is_code_block_line { + last_non_space_col(flat.as_str()) + } else { + last_non_space_col(flat.as_str()).map(|c| c.min(max_x)) + }; + + // Convert selection endpoints into a selection range for this specific visual line: + // - first line clamps the start column + // - last line clamps the end column + // - intermediate lines select the full line. + let line_start_col = if line_index == start.line_index { + start.column + } else { + 0 + }; + let line_end_col = if line_index == end.line_index { + end.column + } else { + max_x.saturating_sub(base_x) + }; + + let row_sel_start = base_x.saturating_add(line_start_col).min(max_x); + + // For code/preformatted lines, treat "selection ends at the viewport edge" as a special + // "copy to end of logical line" case. This prevents narrow terminals from producing + // truncated clipboard content when the user drags to the right edge. + let row_sel_end = if is_code_block_line && line_end_col >= max_x.saturating_sub(base_x) { + u16::MAX + } else { + base_x.saturating_add(line_end_col).min(max_x) + }; + if row_sel_start > row_sel_end { + continue; + } + + let selected_line = if let Some(text_end) = text_end { + let from_col = row_sel_start.max(base_x); + let to_col = row_sel_end.min(text_end); + if from_col > to_col { + Line::default().style(line.style) + } else { + slice_line_by_cols(line, from_col, to_col) + } + } else { + Line::default().style(line.style) + }; + + // Convert the selected `Line` into Markdown source: + // - For prose: wrap inline-code spans in backticks. + // - For code blocks: return the raw flat text so we preserve indentation/spacing. + let line_text = line_to_markdown(&selected_line, is_code_block_line); + + // Track transitions into/out of code/preformatted runs and emit triple-backtick fences. + // We always separate a code run from prior prose with a newline. + if is_code_block_line && !in_code_run { + if wrote_any { + out.push('\n'); + } + out.push_str("```"); + out.push('\n'); + in_code_run = true; + prev_selected_line = None; + wrote_any = true; + } else if !is_code_block_line && in_code_run { + out.push('\n'); + out.push_str("```"); + out.push('\n'); + in_code_run = false; + prev_selected_line = None; + wrote_any = true; + } + + // When copying inside a code run, every selected visual line becomes a literal line inside + // the fence (no soft-wrap joining). We preserve explicit blank lines by writing empty + // strings as a line. + if in_code_run { + if wrote_any && (!out.ends_with('\n') || prev_selected_line.is_some()) { + out.push('\n'); + } + out.push_str(line_text.as_str()); + prev_selected_line = Some(line_index); + wrote_any = true; + continue; + } + + // Prose path: + // - If this line is a soft-wrap continuation of the previous selected line, insert the + // recorded joiner (often spaces) instead of a newline. + // - Otherwise, insert a newline to preserve hard breaks. + if wrote_any { + let joiner = joiner_before.get(line_index).cloned().unwrap_or(None); + if prev_selected_line == Some(line_index.saturating_sub(1)) + && let Some(joiner) = joiner + { + out.push_str(joiner.as_str()); + } else { + out.push('\n'); + } + } + + out.push_str(line_text.as_str()); + prev_selected_line = Some(line_index); + wrote_any = true; } + + if in_code_run { + out.push('\n'); + out.push_str("```"); + } + + (!out.is_empty()).then_some(out) } -/// Whether the given `(ch, modifiers)` should trigger "copy selection". +/// Order two selection endpoints into `(start, end)` in transcript order. /// -/// 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; +/// Dragging can produce reversed endpoints; callers typically want a normalized range before +/// iterating visual lines. +fn order_points( + a: TranscriptSelectionPoint, + b: TranscriptSelectionPoint, +) -> (TranscriptSelectionPoint, TranscriptSelectionPoint) { + if (b.line_index < a.line_index) || (b.line_index == a.line_index && b.column < a.column) { + (b, a) + } else { + (a, b) } +} + +/// Flatten a styled `Line` into its plain text content. +/// +/// This is used for cursor/column arithmetic and for emitting plain-text code lines. +fn line_to_flat(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() +} - 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) +/// Return the last non-space *display column* in `flat` (inclusive). +/// +/// This is display-width-aware, so wide glyphs (e.g. CJK) advance by more than one column. +/// +/// Rationale: transcript rendering often pads out to the viewport width; copy should avoid +/// including that right-margin whitespace. +fn last_non_space_col(flat: &str) -> Option { + use unicode_width::UnicodeWidthChar; + + let mut col: u16 = 0; + let mut last: Option = None; + for ch in flat.chars() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + if ch != ' ' { + let end = col.saturating_add(w.saturating_sub(1)); + last = Some(end); } + col = col.saturating_add(w); } + last } -/// UI state for the on-screen copy affordance shown near an active selection. +/// Map a display-column range to a UTF-8 byte range within `flat`. /// -/// 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, +/// The returned range is suitable for slicing `flat` and for slicing the original `Span` strings +/// (once translated into span-local offsets). +/// +/// This walks Unicode scalar values and advances by display width so callers can slice based on the +/// same column semantics the selection model uses. +fn byte_range_for_cols(flat: &str, start_col: u16, end_col: u16) -> Option> { + use unicode_width::UnicodeWidthChar; + + // We translate selection columns (display columns, not bytes) into a UTF-8 byte range. This is + // intentionally Unicode-width aware: wide glyphs cover multiple columns but occupy one `char` + // and several bytes. + // + // Strategy: + // - Walk `flat` by `char_indices()` while tracking the current display column. + // - The start byte is the first char whose rendered columns intersect `start_col`. + // - The end byte is the end of the last char whose rendered columns intersect `end_col`. + let mut col: u16 = 0; + let mut start_byte: Option = None; + let mut end_byte: Option = None; + + for (idx, ch) in flat.char_indices() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + let end = col.saturating_add(w.saturating_sub(1)); + + // Start is inclusive: select the first glyph whose right edge reaches the start column. + if start_byte.is_none() && end >= start_col { + start_byte = Some(idx); + } + + // End is inclusive in column space; keep extending end byte while we're still at/before + // `end_col`. This includes a wide glyph even if it starts before `end_col` but ends after. + if col <= end_col { + end_byte = Some(idx + ch.len_utf8()); + } + + col = col.saturating_add(w); + if col > end_col && start_byte.is_some() { + break; + } + } + + match (start_byte, end_byte) { + (Some(s), Some(e)) if e >= s => Some(s..e), + _ => None, + } } -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, +/// Slice a styled `Line` by display columns, preserving per-span style. +/// +/// This is the core "selection → styled substring" helper used before Markdown re-encoding. It +/// avoids mixing styles across spans by slicing each contributing span independently, then +/// reassembling them into a new `Line` with the original line-level style. +fn slice_line_by_cols(line: &Line<'static>, start_col: u16, end_col: u16) -> Line<'static> { + // `Line` spans store independent string slices with their own styles. To slice by columns while + // preserving styling, we: + // 1) Flatten the line and compute the desired UTF-8 byte range in the flattened string. + // 2) Compute each span's byte range within the flattened string. + // 3) Intersect the selection range with each span range and slice per-span, preserving styles. + let flat = line_to_flat(line); + let mut span_bounds: Vec<(std::ops::Range, ratatui::style::Style)> = Vec::new(); + let mut acc = 0usize; + for s in &line.spans { + let start = acc; + let text = s.content.as_ref(); + acc += text.len(); + span_bounds.push((start..acc, s.style)); + } + + let Some(range) = byte_range_for_cols(flat.as_str(), start_col, end_col) else { + return Line::default().style(line.style); + }; + + // Translate the flattened byte range back into (span-local) slices. + let start_byte = range.start; + let end_byte = range.end; + let mut spans: Vec> = Vec::new(); + for (i, (r, style)) in span_bounds.iter().enumerate() { + let s = r.start; + let e = r.end; + if e <= start_byte { + continue; + } + if s >= end_byte { + break; + } + let seg_start = start_byte.max(s); + let seg_end = end_byte.min(e); + if seg_end > seg_start { + let local_start = seg_start - s; + let local_end = seg_end - s; + let content = line.spans[i].content.as_ref(); + spans.push(ratatui::text::Span { + style: *style, + content: content[local_start..local_end].to_string().into(), + }); + } + if e >= end_byte { + break; } } + Line::from(spans).style(line.style) +} - pub(crate) fn key_binding(&self) -> KeyBinding { - key_binding_for(self.shortcut) +/// Whether a span should be treated as "inline code" when reconstructing Markdown. +/// +/// TUI2 renders inline code using a cyan foreground. Links also use cyan, but are underlined, so we +/// exclude underlined cyan spans to avoid wrapping links in backticks. +fn span_is_inline_code(span: &Span<'_>) -> bool { + use ratatui::style::Color; + + span.style.fg == Some(Color::Cyan) + && !span + .style + .add_modifier + .contains(ratatui::style::Modifier::UNDERLINED) +} + +/// Convert a selected, styled `Line` back into Markdown-ish source text. +/// +/// - For prose: wraps runs of inline-code spans in backticks to preserve the source marker. +/// - For code blocks: emits the raw flat text (no additional escaping), since the entire run will +/// be wrapped in triple-backtick fences by the caller. +fn line_to_markdown(line: &Line<'static>, is_code_block: bool) -> String { + if is_code_block { + return line_to_flat(line); + } + + let mut out = String::new(); + let mut in_code = false; + for span in &line.spans { + let is_code = span_is_inline_code(span); + if is_code && !in_code { + out.push('`'); + in_code = true; + } else if !is_code && in_code { + out.push('`'); + in_code = false; + } + out.push_str(span.content.as_ref()); + } + if in_code { + out.push('`'); } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use ratatui::style::Color; + use ratatui::style::Style; + use ratatui::style::Stylize; - pub(crate) fn is_copy_key(&self, ch: char, modifiers: KeyModifiers) -> bool { - is_copy_selection_key(self.shortcut, ch, modifiers) + #[test] + fn selection_to_copy_text_returns_none_for_zero_content_width() { + let lines = vec![Line::from("• Hello")]; + let joiner_before = vec![None]; + let start = TranscriptSelectionPoint { + line_index: 0, + column: 0, + }; + let end = TranscriptSelectionPoint { + line_index: 0, + column: 1, + }; + + assert_eq!( + selection_to_copy_text( + &lines, + &joiner_before, + start, + end, + 0, + lines.len(), + TRANSCRIPT_GUTTER_COLS, + ), + None + ); } - pub(crate) fn set_dragging(&mut self, dragging: bool) { - self.dragging = dragging; + #[test] + fn selection_to_copy_text_returns_none_for_empty_selection_point() { + let lines = vec![Line::from("• Hello")]; + let joiner_before = vec![None]; + let pt = TranscriptSelectionPoint { + line_index: 0, + column: 0, + }; + + assert_eq!( + selection_to_copy_text(&lines, &joiner_before, pt, pt, 0, lines.len(), 20), + None + ); } - pub(crate) fn clear_affordance(&mut self) { - self.affordance_rect = None; + #[test] + fn selection_to_copy_text_orders_reversed_endpoints() { + let lines = vec![Line::from("• Hello world")]; + let joiner_before = vec![None]; + + let start = TranscriptSelectionPoint { + line_index: 0, + column: 10, + }; + let end = TranscriptSelectionPoint { + line_index: 0, + column: 6, + }; + + let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, 1, 80) + .expect("expected text"); + + assert_eq!(out, "world"); } - /// 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()) + #[test] + fn copy_selection_soft_wrap_joins_without_newline() { + let lines = vec![Line::from("• Hello"), Line::from(" world")]; + let joiner_before = vec![None, Some(" ".to_string())]; + let start = TranscriptSelectionPoint { + line_index: 0, + column: 0, + }; + let end = TranscriptSelectionPoint { + line_index: 1, + column: 100, + }; + + let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, lines.len(), 20) + .expect("expected text"); + + assert_eq!(out, "Hello world"); } - /// 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; - } + #[test] + fn copy_selection_wraps_inline_code_in_backticks() { + let lines = vec![Line::from(vec![ + "• ".into(), + "Use ".into(), + ratatui::text::Span::from("foo()").style(Style::new().fg(Color::Cyan)), + " now".into(), + ])]; + let joiner_before = vec![None]; + let start = TranscriptSelectionPoint { + line_index: 0, + column: 0, + }; + let end = TranscriptSelectionPoint { + line_index: 0, + column: 100, + }; - // 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; - } + let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, 1, 80) + .expect("expected text"); + + assert_eq!(out, "Use `foo()` now"); + } - // 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); + #[test] + fn selection_to_copy_text_for_cells_reconstructs_full_code_line_beyond_viewport() { + #[derive(Debug)] + struct FakeCell { + lines: Vec>, + joiner_before: Vec>, } - // 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; + impl HistoryCell for FakeCell { + fn display_lines(&self, _width: u16) -> Vec> { + self.lines.clone() } - 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); + fn transcript_lines_with_joiners( + &self, + _width: u16, + ) -> crate::history_cell::TranscriptLinesWithJoiners { + crate::history_cell::TranscriptLinesWithJoiners { + lines: self.lines.clone(), + joiner_before: self.joiner_before.clone(), } } + } - let Some(text_end) = last_text_x else { - continue; - }; + let style = Style::new().fg(Color::Cyan); + let cell = FakeCell { + lines: vec![Line::from("• 0123456789ABCDEFGHIJ").style(style)], + joiner_before: vec![None], + }; + let cells: Vec> = vec![std::sync::Arc::new(cell)]; - 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; - } + let width: u16 = 12; + let max_x = width.saturating_sub(1); + let viewport_edge_col = max_x.saturating_sub(TRANSCRIPT_GUTTER_COLS); - // 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)); - } + let selection = TranscriptSelection { + anchor: Some(TranscriptSelectionPoint::new(0, 0)), + head: Some(TranscriptSelectionPoint::new(0, viewport_edge_col)), + }; + + let out = + selection_to_copy_text_for_cells(&cells, selection, width).expect("expected text"); + assert_eq!(out, "```\n 0123456789ABCDEFGHIJ\n```"); + } + + #[test] + fn order_points_orders_by_line_then_column() { + let a = TranscriptSelectionPoint::new(2, 5); + let b = TranscriptSelectionPoint::new(1, 10); + assert_eq!(order_points(a, b), (b, a)); + + let a = TranscriptSelectionPoint::new(1, 5); + let b = TranscriptSelectionPoint::new(1, 10); + assert_eq!(order_points(a, b), (a, b)); + } + + #[test] + fn line_to_flat_concatenates_spans() { + let line = Line::from(vec!["a".into(), "b".into(), "c".into()]); + assert_eq!(line_to_flat(&line), "abc"); + } + + #[test] + fn last_non_space_col_counts_display_width() { + // "コ" is width 2, so "コX" occupies columns 0..=2. + assert_eq!(last_non_space_col("コX"), Some(2)); + assert_eq!(last_non_space_col("a "), Some(0)); + assert_eq!(last_non_space_col(" "), None); + } + + #[test] + fn byte_range_for_cols_maps_columns_to_utf8_bytes() { + let flat = "abcd"; + let range = byte_range_for_cols(flat, 1, 2).expect("range"); + assert_eq!(&flat[range], "bc"); + + let flat = "コX"; + let range = byte_range_for_cols(flat, 0, 2).expect("range"); + assert_eq!(&flat[range], "コX"); + } + + #[test] + fn slice_line_by_cols_preserves_span_styles() { + let line = Line::from(vec![ + "• ".into(), + "Hello".red(), + " ".into(), + "world".green(), + ]); + + // Slice "llo wo" (crosses span boundaries). + let sliced = slice_line_by_cols(&line, 4, 9); + assert_eq!(line_to_flat(&sliced), "llo wo"); + assert_eq!(sliced.spans.len(), 3); + assert_eq!(sliced.spans[0].content.as_ref(), "llo"); + assert_eq!(sliced.spans[0].style.fg, Some(Color::Red)); + assert_eq!(sliced.spans[1].content.as_ref(), " "); + assert_eq!(sliced.spans[2].content.as_ref(), "wo"); + assert_eq!(sliced.spans[2].style.fg, Some(Color::Green)); + } + + #[test] + fn span_is_inline_code_excludes_underlined_cyan() { + let inline_code = Span::from("x").style(Style::new().fg(Color::Cyan)); + assert!(span_is_inline_code(&inline_code)); + + let link_like = Span::from("x").style(Style::new().fg(Color::Cyan).underlined()); + assert!(!span_is_inline_code(&link_like)); + + let other = Span::from("x").style(Style::new().fg(Color::Green)); + assert!(!span_is_inline_code(&other)); + } - // If nothing in the selection is visible, don't show the affordance. - let Some((y, to_x)) = last_visible_segment else { - return; + #[test] + fn line_to_markdown_wraps_contiguous_inline_code_spans() { + let line = Line::from(vec![ + "Use ".into(), + Span::from("foo").style(Style::new().fg(Color::Cyan)), + Span::from("()").style(Style::new().fg(Color::Cyan)), + " now".into(), + ]); + assert_eq!(line_to_markdown(&line, false), "Use `foo()` now"); + } + + #[test] + fn copy_selection_preserves_wide_glyphs() { + let lines = vec![Line::from("• コX")]; + let joiner_before = vec![None]; + + let start = TranscriptSelectionPoint { + line_index: 0, + column: 0, }; - // 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 end = TranscriptSelectionPoint { + line_index: 0, + column: 2, }; - let key_label: Span<'static> = self.key_binding().into(); - let key_label = key_label.content.as_ref().to_string(); + let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, 1, 80) + .expect("expected text"); - let pill_text = format!(" ⧉ copy {key_label} "); - let pill_width = UnicodeWidthStr::width(pill_text.as_str()); - if pill_width == 0 || area.width == 0 { - return; - } + assert_eq!(out, "コX"); + } - 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) + #[test] + fn copy_selection_wraps_code_block_in_fences_and_preserves_indent() { + let style = Style::new().fg(Color::Cyan); + let lines = vec![ + Line::from("• fn main() {}").style(style), + Line::from(" println!(\"hi\");").style(style), + ]; + let joiner_before = vec![None, None]; + let start = TranscriptSelectionPoint { + line_index: 0, + column: 0, + }; + let end = TranscriptSelectionPoint { + line_index: 1, + column: 100, }; - 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> = 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)); + let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, lines.len(), 80) + .expect("expected text"); - Paragraph::new(vec![Line::from(spans)]).render_ref(pill_area, buf); - self.affordance_rect = Some(pill_area); + assert_eq!(out, "```\n fn main() {}\n println!(\"hi\");\n```"); } -} -#[cfg(test)] -mod tests { - use super::*; - use ratatui::buffer::Buffer; + #[test] + fn copy_selection_code_block_end_col_at_viewport_edge_copies_full_line() { + let style = Style::new().fg(Color::Cyan); + let lines = vec![Line::from("• 0123456789ABCDEFGHIJ").style(style)]; + let joiner_before = vec![None]; + + let width: u16 = 12; + let max_x = width.saturating_sub(1); + let viewport_edge_col = max_x.saturating_sub(TRANSCRIPT_GUTTER_COLS); + + let start = TranscriptSelectionPoint { + line_index: 0, + column: 0, + }; + let end = TranscriptSelectionPoint { + line_index: 0, + column: viewport_edge_col, + }; - 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 + let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, 1, width) + .expect("expected text"); + + assert_eq!(out, "```\n 0123456789ABCDEFGHIJ\n```"); } #[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"); - } - } + fn copy_selection_code_block_end_col_before_viewport_edge_copies_partial_line() { + let style = Style::new().fg(Color::Cyan); + let lines = vec![Line::from("• 0123456789ABCDEFGHIJ").style(style)]; + let joiner_before = vec![None]; + + let width: u16 = 12; + + let start = TranscriptSelectionPoint { + line_index: 0, + column: 0, + }; + let end = TranscriptSelectionPoint { + line_index: 0, + column: 7, + }; - let mut ui = TranscriptCopyUi::new_with_shortcut(CopySelectionShortcut::CtrlY); - ui.render_copy_pill(area, &mut buf, (1, 2), (1, 6), 0, 3); + let out = selection_to_copy_text(&lines, &joiner_before, start, end, 0, 1, width) + .expect("expected text"); - 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()); + assert_eq!(out, "```\n 0123\n```"); } } diff --git a/codex-rs/tui2/src/transcript_copy_ui.rs b/codex-rs/tui2/src/transcript_copy_ui.rs new file mode 100644 index 00000000000..6c852c23416 --- /dev/null +++ b/codex-rs/tui2/src/transcript_copy_ui.rs @@ -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, +} + +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> = 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()); + } +} diff --git a/codex-rs/tui2/src/transcript_render.rs b/codex-rs/tui2/src/transcript_render.rs new file mode 100644 index 00000000000..ceee03d8978 --- /dev/null +++ b/codex-rs/tui2/src/transcript_render.rs @@ -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>, + /// 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, + /// 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>, +} + +/// 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], + 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> = Vec::new(); + let mut meta: Vec = Vec::new(); + let mut joiner_before: Vec> = 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], + 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> = Vec::new(); + let mut meta: Vec = Vec::new(); + let mut joiner_before: Vec> = 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 { + 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> = 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 = 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>, + joiner_before: Vec>, + is_stream_continuation: bool, + } + + impl HistoryCell for FakeCell { + fn display_lines(&self, _width: u16) -> Vec> { + 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::() + } + + #[test] + fn build_wrapped_transcript_lines_threads_joiners_and_spacers() { + let cells: Vec> = 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 = 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()), + ] + ); + } +} diff --git a/codex-rs/tui2/src/transcript_selection.rs b/codex-rs/tui2/src/transcript_selection.rs index aa7cf18acea..f16bbaef775 100644 --- a/codex-rs/tui2/src/transcript_selection.rs +++ b/codex-rs/tui2/src/transcript_selection.rs @@ -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, - /// 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, } -impl TranscriptSelection { - /// Create an active selection with both endpoints set. - #[cfg(test)] - pub(crate) fn new( - anchor: impl Into, - head: impl Into, - ) -> 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 { - 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 = 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>> { - 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 { - 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 { - (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); diff --git a/codex-rs/tui2/src/wrapping.rs b/codex-rs/tui2/src/wrapping.rs index c29106651d4..00c2812bb77 100644 --- a/codex-rs/tui2/src/wrapping.rs +++ b/codex-rs/tui2/src/wrapping.rs @@ -155,6 +155,13 @@ pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> where O: Into>, { + 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, 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, ratatui::style::Style)], + range: &Range, +) -> 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>, Vec>) +where + O: Into>, +{ + 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> = Vec::new(); + let mut joiners: Vec> = 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); - } - - // Wrap the remainder using subsequent indent width and map back to original indices. - let base = first_line_range.end; + 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. 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>, Vec>) +where + I: IntoIterator, + L: IntoLineInput<'a>, + O: Into>, +{ + let base_opts: RtOptions<'a> = width_or_options.into(); + let mut out: Vec> = Vec::new(); + let mut joiners: Vec> = 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)