Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions codex-rs/tui2/docs/tui_viewport_and_history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
201 changes: 40 additions & 161 deletions codex-rs/tui2/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -570,13 +566,15 @@ impl App {
let session_lines = if width == 0 {
Vec::new()
} else {
let (lines, line_meta) = Self::build_transcript_lines(&app.transcript_cells, width);
let transcript =
crate::transcript_render::build_transcript_lines(&app.transcript_cells, width);
let (lines, line_meta) = (transcript.lines, transcript.meta);
let is_user_cell: Vec<bool> = app
.transcript_cells
.iter()
.map(|cell| cell.as_any().is::<UserHistoryCell>())
.collect();
Self::render_lines_to_ansi(&lines, &line_meta, &is_user_cell, width)
crate::transcript_render::render_lines_to_ansi(&lines, &line_meta, &is_user_cell, width)
};

tui.terminal.clear()?;
Expand Down Expand Up @@ -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();
Expand All @@ -720,40 +720,12 @@ impl App {
return area.y;
}

let wrapped = word_wrap_lines_borrowed(&lines, transcript_area.width.max(1) as usize);
if wrapped.is_empty() {
self.transcript_scroll = TranscriptScroll::default();
self.transcript_view_top = 0;
self.transcript_total_lines = 0;
return area.y;
}

let is_user_cell: Vec<bool> = cells
.iter()
.map(|c| c.as_any().is::<UserHistoryCell>())
.collect();
let base_opts: RtOptions<'_> = RtOptions::new(transcript_area.width.max(1) as usize);
let mut wrapped_is_user_row: Vec<bool> = Vec::with_capacity(wrapped.len());
let mut first = true;
for (idx, line) in lines.iter().enumerate() {
let opts = if first {
base_opts.clone()
} else {
base_opts
.clone()
.initial_indent(base_opts.subsequent_indent.clone())
};
let seg_count = word_wrap_line(line, opts).len();
let is_user_row = line_meta
.get(idx)
.and_then(TranscriptLineMeta::cell_index)
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
.unwrap_or(false);
wrapped_is_user_row.extend(std::iter::repeat_n(is_user_row, seg_count));
first = false;
}

let total_lines = wrapped.len();
let total_lines = lines.len();
self.transcript_total_lines = total_lines;
let max_visible = std::cmp::min(max_transcript_height as usize, total_lines);
let max_start = total_lines.saturating_sub(max_visible);
Expand Down Expand Up @@ -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)];
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand All @@ -1174,111 +1151,6 @@ impl App {
}
}

/// Build the flattened transcript lines for rendering, scrolling, and exit transcripts.
///
/// Returns both the visible `Line` buffer and a parallel metadata vector
/// that maps each line back to its originating `(cell_index, line_in_cell)`
/// pair (see `TranscriptLineMeta::CellLine`), or `TranscriptLineMeta::Spacer` for
/// synthetic spacer rows inserted between cells. This allows the scroll state
/// to anchor to a specific history cell even as new content arrives or the
/// viewport size changes, and gives exit transcript renderers enough structure
/// to style user rows differently from agent rows.
fn build_transcript_lines(
cells: &[Arc<dyn HistoryCell>],
width: u16,
) -> (Vec<Line<'static>>, Vec<TranscriptLineMeta>) {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut line_meta: Vec<TranscriptLineMeta> = Vec::new();
let mut has_emitted_lines = false;

for (cell_index, cell) in cells.iter().enumerate() {
let cell_lines = cell.display_lines(width);
if cell_lines.is_empty() {
continue;
}

if !cell.is_stream_continuation() {
if has_emitted_lines {
lines.push(Line::from(""));
line_meta.push(TranscriptLineMeta::Spacer);
} else {
has_emitted_lines = true;
}
}

for (line_in_cell, line) in cell_lines.into_iter().enumerate() {
line_meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
});
lines.push(line);
}
}

(lines, line_meta)
}

/// Render flattened transcript lines into ANSI strings suitable for
/// printing after the TUI exits.
///
/// This helper mirrors the original TUI viewport behavior:
/// - Merges line-level style into each span so the ANSI output matches
/// the on-screen styling (e.g., blockquotes, lists).
/// - For user-authored rows, pads the background style out to the full
/// terminal width so prompts appear as solid blocks in scrollback.
/// - Streams spans through the shared vt100 writer so downstream tests
/// and tools see consistent escape sequences.
fn render_lines_to_ansi(
lines: &[Line<'static>],
line_meta: &[TranscriptLineMeta],
is_user_cell: &[bool],
width: u16,
) -> Vec<String> {
lines
.iter()
.enumerate()
.map(|(idx, line)| {
let is_user_row = line_meta
.get(idx)
.and_then(TranscriptLineMeta::cell_index)
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
.unwrap_or(false);

let mut merged_spans: Vec<ratatui::text::Span<'static>> = line
.spans
.iter()
.map(|span| ratatui::text::Span {
style: span.style.patch(line.style),
content: span.content.clone(),
})
.collect();

if is_user_row && width > 0 {
let text: String = merged_spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let text_width = UnicodeWidthStr::width(text.as_str());
let total_width = usize::from(width);
if text_width < total_width {
let pad_len = total_width.saturating_sub(text_width);
if pad_len > 0 {
let pad_style = crate::style::user_message_style();
merged_spans.push(ratatui::text::Span {
style: pad_style,
content: " ".repeat(pad_len).into(),
});
}
}
}

let mut buf: Vec<u8> = Vec::new();
let _ = crate::insert_history::write_spans(&mut buf, merged_spans.iter());
String::from_utf8(buf).unwrap_or_default()
})
.collect()
}

/// Apply the current transcript selection to the given buffer.
///
/// The selection is defined in terms of flattened wrapped transcript line
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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"));
}
Expand Down
Loading
Loading