Compare commits

...

6 Commits

Author SHA1 Message Date
Josh McKinney
97c54634f7 feat(tui2): add inline transcript find
Add an in-viewport find experience for the TUI2 transcript (the
scrollable region above the composer), without introducing a persistent
search bar or scroll indicator.

UX:
- Ctrl-F opens a 1-row "/ " prompt above the composer and updates
  highlights live as you type
- Ctrl-G jumps to the next match without closing the prompt, and keeps
  working after the prompt closes while the query is still active
- Esc closes the prompt but keeps the active query/highlights; Esc again
  clears the search
- Enter closes the prompt and jumps to the selected match

Implementation:
- Add a dedicated transcript_find module to own query/edit state,
  smart-case matching over flattened transcript lines, stable jump
  anchoring (cell + line-in-cell), and per-line highlight rendering
- Keep app.rs integration additive via small delegation calls from the
  key handler and render loop
- Plumb find visibility to the footer so shortcuts show Ctrl-G next match
  only while the find prompt is visible

Docs/tests:
- Add tui2/docs/transcript_find.md documenting current behavior vs the
  ideal/perfect end state and explicitly calling out deferred work
- Stabilize VT100-based rendering tests by forcing color output and
  emitting crossterm fg/bg colors directly in insert-history output
2025-12-23 13:47:45 -08:00
Josh McKinney
060c44ff78 test(tui2): narrow disallowed_methods suppression
The SGR writer tests intentionally construct truecolor (RGB) and indexed colors
so the emitted escape sequences are deterministic.

Replace the blanket allow on the whole test with statement-level
`#[expect(clippy::disallowed_methods)]` on the specific constructors
(`Color::Rgb`/`Color::Indexed`), with inline rationale.
2025-12-23 12:28:13 -08:00
Josh McKinney
bc9ab79d09 feat(tui2): select whole cell on quint-click
Add a 5+ click gesture to select the entire history cell in the transcript.

Implementation notes:
- Selection is computed in transcript/viewport coordinates (wrapped visual line
  indices + content columns), not terminal buffer coordinates.
- We rebuild the wrapped transcript view and carry forward a mapping from each
  wrapped line to its originating HistoryCell index.
- Quint-click expands to the contiguous wrapped-line range for that cell; if the
  click lands on a spacer line between cells, it selects the cell above (falling
  back to the next cell below).

Tests cover selecting the full cell (including blank lines inside the cell) and
quint-click behavior on spacer lines.
2025-12-23 12:28:11 -08:00
Josh McKinney
4ce57ef890 fix(tui2): improve multi-click selection reliability
Multi-click selection was flaky because we reset the click tracker on any mouse
drag event. Some terminals emit Drag events for very small cursor motion while
the button is held down (trackpad jitter), which breaks double/quad click
sequences.

Treat small drags as jitter: only reset the click tracker once the cursor moves
to another wrapped line or far enough horizontally from the anchor. Also loosen
click grouping as the sequence progresses and slightly increase the inter-click
timeout so quad-clicks are practical.

Add tests covering drag jitter and relaxed quad-click grouping.
2025-12-23 10:25:40 -08:00
Josh McKinney
c84b2eb22c feat(tui2): add multi-click transcript selection
Support double/triple/quad click selection (word/line/paragraph) using
transcript/viewport coordinates rather than terminal buffer positions.

Multi-click expansion rebuilds the wrapped transcript view from
HistoryCell::display_lines(width) so boundaries match on-screen wrapping
during scroll/resize/streaming reflow. Drag selection resets the click
tracker to avoid accidental multi-click accumulation.

Tests cover click expansion, resets (time, movement, line change, drag),
and paragraph detection across spacer lines between history cells.
2025-12-22 23:36:33 -08:00
Josh McKinney
c8901b3784 feat(tui2): improve transcript copy fidelity
Improve clipboard output for transcript selections, even when content is wrapped.

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

View File

@@ -0,0 +1,248 @@
# Transcript Find (Inline Viewport)
This document describes the design for “find in transcript” in the **TUI2 inline viewport** (the
main transcript region above the composer), not the full-screen transcript overlay.
The goal is to provide fast, low-friction navigation through the in-memory transcript while keeping
the UI predictable and the implementation easy to review/maintain.
---
## Goals
- **Search the inline viewport content**, derived from the same flattened transcript lines used for
scrolling/selection, so search results track what the user sees.
- **Ephemeral UI**: no always-on search bar and no scroll bar in this iteration.
- **Fast navigation**:
- highlight all matches
- jump to the next match repeatedly without reopening the prompt
- **Stable anchoring**: jumping should land on stable content anchors (cell + line), not raw screen
rows.
- **Reviewable architecture**: keep `app.rs` changes small by placing feature logic in a dedicated
module and calling it from the render loop and key handler.
---
## Current Implementation (What We Have Today)
This section documents the current state so its easy to compare against the “ideal/perfect” end
state discussed in review.
For implementation details, see the rustdoc comments and unit tests in `tui2/src/transcript_find.rs`.
### UI
- When active, a single prompt row is rendered **above the composer**:
- `"/ query current/total"`
- Matches are highlighted in the transcript:
- all matches: underlined
- current match: reversed + bold + underlined
- The prompt is **not persistent**: it only appears while editing.
### Keys
- `Ctrl-F`: open the find prompt and start editing the query.
- While editing:
- type to edit the query (highlights update as you type)
- `Backspace`: delete one character
- `Ctrl-U`: clear the query
- `Enter`: close the prompt and jump to a match (if any)
- `Esc`: close the prompt without clearing the query (highlights remain)
- `Ctrl-G`: jump to next match.
- Works while editing (prompt stays open).
- Works even after the prompt is closed, as long as the query is still active.
- `Esc` (when not editing and a query is active): clears the search/highlights.
### Footer hints
- When the find prompt is visible, the footer shows `Ctrl-G next match`:
- in the shortcut summary line
- and in the `?` shortcut overlay
### Implementation layout
- Core logic lives in `tui2/src/transcript_find.rs`:
- key handling
- match computation/caching
- jump selection
- per-line rendering helper (`render_line`) and prompt rendering helper (`render_prompt_line`)
- `tui2/src/app.rs` is kept mostly additive by delegating:
- early key handling delegation in `App::handle_key_event`
- per-frame recompute/jump hook after transcript flattening
- per-row render hook for match highlighting
- prompt + cursor positioning while editing
- Footer hint integration is wired via `set_transcript_ui_state(..., find_visible)` through:
- `tui2/src/chatwidget.rs`
- `tui2/src/bottom_pane/mod.rs`
- `tui2/src/bottom_pane/chat_composer.rs`
- `tui2/src/bottom_pane/footer.rs`
---
## UX and Keybindings
### Entering search
- `Ctrl-F` opens the find prompt on the line immediately above the composer.
- While the prompt is open, typed characters update the query and immediately update highlights.
### Navigating results
- `Ctrl-G` jumps to the next match.
- Works while the prompt is open.
- Also works after the prompt is closed as long as a non-empty query is still active (so users can
“keep stepping” through matches).
### Exiting / clearing
- `Esc` closes the prompt without clearing the active query (and therefore keeps highlights).
- `Esc` again (when not editing and a query is active) clears the search/highlights.
### Footer hints
When the find prompt is visible, we surface the relevant navigation key (`Ctrl-G`) in:
- the shortcut summary line (the default footer mode)
- the “?” shortcut overlay
This keeps the prompt itself visually minimal.
---
## Data Model: Search Over Flattened Lines
Search operates over the same representation as scrolling and selection:
1. Cells are flattened into a list of `Line<'static>` plus parallel `TranscriptLineMeta` entries
(see `tui2/src/tui/scrolling.rs` and `tui2/docs/tui_viewport_and_history.md`).
2. The find module searches **plain text** extracted from each flattened line (by concatenating its
spans contents).
3. Each match stores:
- `line_index` (index into flattened lines)
- `range` (byte range within the flattened lines plain text)
- `anchor` derived from `TranscriptLineMeta::CellLine { cell_index, line_in_cell }`
The anchor is used to update `TranscriptScroll` when jumping so the viewport lands on stable content
even if the transcript grows.
---
## Matching Semantics
### Smart-case
The search is “smart-case”:
- If the query contains any ASCII uppercase, the match is case-sensitive.
- Otherwise, both haystack and needle are matched in ASCII-lowercased form.
This avoids expensive Unicode case folding and keeps behavior predictable in terminals.
---
## Rendering
### Highlights
- All matches are highlighted (currently: underlined).
- The “current match” is emphasized more strongly (currently: reversed + bold + underlined).
Highlighting is applied at render time for each visible line by splitting spans into segments and
patching styles for the match ranges.
### Prompt line
While editing, the line directly above the composer shows:
`/ query current/total`
It is rendered inside the transcript viewport area (not as a persistent UI element), and the cursor
is moved into this line while editing.
---
## Performance / Caching
Recomputing matches happens only when needed. The search module caches based on:
- transcript width (wrapping changes can change the flattened line list)
- number of flattened lines (transcript growth)
This keeps the work proportional to actual content changes rather than every frame.
---
## Code Layout (Additive, Review-Friendly)
The implementation is structured so `app.rs` only delegates:
- `tui2/src/transcript_find.rs` owns:
- query/edit state
- match computation and caching
- key handling for find-related shortcuts
- rendering helpers for highlighted lines and the prompt line
- producing a scroll anchor when a jump is requested
`app.rs` integration points are intentionally small:
- **Key handling**: early delegation to `TranscriptFind::handle_key_event`.
- **Render**:
- call `TranscriptFind::on_render` after building flattened lines to apply pending jumps
- call `TranscriptFind::render_line` per visible row
- render `render_prompt_line` when active and set cursor with `cursor_position`
- **Footer**:
- `set_transcript_ui_state(..., find_visible)` so the footer can show find-related hints only when
the prompt is visible.
---
## Comparison to the “Ideal” End State
### Ideal UX (what “perfect” looks like)
- **Ephemeral, minimal UI**: no always-on search bar, and no scroll bar for this feature.
- **Fast entry**: `Ctrl-F` opens a single prompt row above the composer.
- **Live feedback**: highlights update as you type, and the prompt shows `current/total`.
- **Repeat navigation without closing**: `Ctrl-G` jumps to the next match while the prompt stays
open, and continues to work after the prompt closes as long as the query is active.
- **Predictable exit semantics**:
- `Enter`: accept query, close prompt, and jump (if any matches)
- `Esc`: close the prompt but keep the query/highlights
- `Esc` again (with an active query): clear the query/highlights
- **Stable jumping**: navigation targets stable transcript anchors (cell + line-in-cell), so jumping
behaves well as the transcript grows.
- **Discoverability without clutter**: when the prompt is visible, the footer/shortcuts surface the
navigation key (`Ctrl-G`) so the prompt itself stays tight.
- **Future marker integration**: if/when a scroll indicator is introduced, match markers integrate
with it (faint ticks for match lines, stronger marker for the current match).
### Already aligned with the ideal
- Ephemeral prompt (no always-on bar).
- Live highlighting while typing.
- `Ctrl-G` repeat navigation without reopening the prompt (including while editing).
- Stable jump anchoring via `(cell_index, line_in_cell)` metadata.
- Footer hints (`Ctrl-G next match`) shown only while the prompt is visible.
- Minimal, review-friendly integration points in `app.rs` via `tui2/src/transcript_find.rs`.
### Not implemented yet (intentional deferrals)
- Prev match (e.g. `Ctrl-Shift-G`).
- “Contextual landing” when jumping (e.g. padding/centering so the match isnt pinned to the top).
- Match markers integrated with a future scroll indicator.
### Known limitations / trade-offs in the current version
- Matching is ASCII smart-case (no full Unicode case folding).
- Match ranges are byte ranges in the flattened plain text. This is fine for styling spans by byte
slicing, but any future “column-precise” behaviors should be careful with multi-byte characters.
---
## Future Work (Not Implemented Here)
- **Prev match**: add `Ctrl-Shift-G` for previous match if desired.
- **Marker integration**: if/when a scroll indicator is added, include match markers derived from
match line indices (faint ticks) and a stronger marker for the current match.
- **Contextual jump placement**: center the current match (or provide padding above) rather than
placing it at the exact top row when jumping.

View File

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

View File

@@ -17,7 +17,9 @@ 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_find::TranscriptFind;
use crate::transcript_multi_click::TranscriptMultiClick;
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
use crate::transcript_selection::TranscriptSelection;
use crate::transcript_selection::TranscriptSelectionPoint;
@@ -31,9 +33,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 +79,6 @@ use std::thread;
use std::time::Duration;
use tokio::select;
use tokio::sync::mpsc::unbounded_channel;
use unicode_width::UnicodeWidthStr;
#[cfg(not(debug_assertions))]
use crate::history_cell::UpdateAvailableHistoryCell;
@@ -333,9 +331,11 @@ pub(crate) struct App {
#[allow(dead_code)]
transcript_scroll: TranscriptScroll,
transcript_selection: TranscriptSelection,
transcript_multi_click: TranscriptMultiClick,
transcript_view_top: usize,
transcript_total_lines: usize,
transcript_copy_ui: TranscriptCopyUi,
transcript_find: TranscriptFind,
// Pager overlay state (Transcript or Static like Diff)
pub(crate) overlay: Option<Overlay>,
@@ -363,7 +363,6 @@ pub(crate) struct App {
// One-shot suppression of the next world-writable scan after user confirmation.
skip_world_writable_scan_once: bool,
}
impl App {
async fn shutdown_current_conversation(&mut self) {
if let Some(conversation_id) = self.chat_widget.conversation_id() {
@@ -486,7 +485,7 @@ impl App {
},
);
let copy_selection_shortcut = crate::transcript_copy::detect_copy_selection_shortcut();
let copy_selection_shortcut = crate::transcript_copy_ui::detect_copy_selection_shortcut();
let mut app = Self {
server: conversation_manager.clone(),
@@ -501,9 +500,11 @@ impl App {
transcript_cells: Vec::new(),
transcript_scroll: TranscriptScroll::default(),
transcript_selection: TranscriptSelection::default(),
transcript_multi_click: TranscriptMultiClick::default(),
transcript_view_top: 0,
transcript_total_lines: 0,
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut),
transcript_find: TranscriptFind::default(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -570,13 +571,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()?;
@@ -651,7 +654,11 @@ impl App {
frame.buffer,
);
}
if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) {
if let Some((x, y)) =
self.transcript_find.cursor_position(frame.area(), chat_top)
{
frame.set_cursor_position((x, y));
} else if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) {
frame.set_cursor_position((x, y));
}
})?;
@@ -674,6 +681,7 @@ impl App {
selection_active,
scroll_position,
self.copy_selection_key(),
self.transcript_find.is_visible(),
);
}
}
@@ -711,7 +719,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 +730,23 @@ 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;
if let Some((cell_index, line_in_cell)) = self.transcript_find.on_render(
&lines,
&line_meta,
transcript_area.width,
self.transcript_view_top,
) {
self.transcript_scroll = TranscriptScroll::Scrolled {
cell_index,
line_in_cell,
};
}
let is_user_cell: Vec<bool> = cells
.iter()
.map(|c| c.as_any().is::<UserHistoryCell>())
.collect();
let base_opts: RtOptions<'_> = RtOptions::new(transcript_area.width.max(1) as usize);
let mut wrapped_is_user_row: Vec<bool> = Vec::with_capacity(wrapped.len());
let mut first = true;
for (idx, line) in lines.iter().enumerate() {
let opts = if first {
base_opts.clone()
} else {
base_opts
.clone()
.initial_indent(base_opts.subsequent_indent.clone())
};
let seg_count = word_wrap_line(line, opts).len();
let is_user_row = line_meta
.get(idx)
.and_then(TranscriptLineMeta::cell_index)
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
.unwrap_or(false);
wrapped_is_user_row.extend(std::iter::repeat_n(is_user_row, seg_count));
first = false;
}
let total_lines = wrapped.len();
let total_lines = lines.len();
self.transcript_total_lines = total_lines;
let max_visible = std::cmp::min(max_transcript_height as usize, total_lines);
let max_start = total_lines.saturating_sub(max_visible);
@@ -805,11 +798,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 +812,10 @@ impl App {
}
}
wrapped[line_index].render_ref(row_area, frame.buffer);
let line = self
.transcript_find
.render_line(line_index, &lines[line_index]);
line.render_ref(row_area, frame.buffer);
}
self.apply_transcript_selection(transcript_area, frame.buffer);
@@ -838,6 +835,19 @@ impl App {
} else {
self.transcript_copy_ui.clear_affordance();
}
if let Some(prompt_line) = self.transcript_find.render_prompt_line()
&& chat_top > area.y
{
let prompt_area = Rect {
x: area.x,
y: chat_top.saturating_sub(1),
width: area.width,
height: 1,
};
Clear.render_ref(prompt_area, frame.buffer);
Paragraph::new(prompt_line).render_ref(prompt_area, frame.buffer);
}
chat_top
}
@@ -965,8 +975,12 @@ impl App {
clamped_x,
clamped_y,
);
if crate::transcript_selection::on_mouse_down(&mut self.transcript_selection, point)
{
if self.transcript_multi_click.on_mouse_down(
&mut self.transcript_selection,
&self.transcript_cells,
transcript_area.width,
point,
) {
tui.frame_requester().schedule_frame();
}
}
@@ -983,6 +997,8 @@ impl App {
point,
streaming,
);
self.transcript_multi_click
.on_mouse_drag(&self.transcript_selection, point);
if outcome.lock_scroll {
self.lock_transcript_scroll_to_current_view(
transcript_area.height as usize,
@@ -1125,7 +1141,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 +1167,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 +1194,6 @@ impl App {
}
}
/// Build the flattened transcript lines for rendering, scrolling, and exit transcripts.
///
/// Returns both the visible `Line` buffer and a parallel metadata vector
/// that maps each line back to its originating `(cell_index, line_in_cell)`
/// pair (see `TranscriptLineMeta::CellLine`), or `TranscriptLineMeta::Spacer` for
/// synthetic spacer rows inserted between cells. This allows the scroll state
/// to anchor to a specific history cell even as new content arrives or the
/// viewport size changes, and gives exit transcript renderers enough structure
/// to style user rows differently from agent rows.
fn build_transcript_lines(
cells: &[Arc<dyn HistoryCell>],
width: u16,
) -> (Vec<Line<'static>>, Vec<TranscriptLineMeta>) {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut line_meta: Vec<TranscriptLineMeta> = Vec::new();
let mut has_emitted_lines = false;
for (cell_index, cell) in cells.iter().enumerate() {
let cell_lines = cell.display_lines(width);
if cell_lines.is_empty() {
continue;
}
if !cell.is_stream_continuation() {
if has_emitted_lines {
lines.push(Line::from(""));
line_meta.push(TranscriptLineMeta::Spacer);
} else {
has_emitted_lines = true;
}
}
for (line_in_cell, line) in cell_lines.into_iter().enumerate() {
line_meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
});
lines.push(line);
}
}
(lines, line_meta)
}
/// Render flattened transcript lines into ANSI strings suitable for
/// printing after the TUI exits.
///
/// This helper mirrors the original TUI viewport behavior:
/// - Merges line-level style into each span so the ANSI output matches
/// the on-screen styling (e.g., blockquotes, lists).
/// - For user-authored rows, pads the background style out to the full
/// terminal width so prompts appear as solid blocks in scrollback.
/// - Streams spans through the shared vt100 writer so downstream tests
/// and tools see consistent escape sequences.
fn render_lines_to_ansi(
lines: &[Line<'static>],
line_meta: &[TranscriptLineMeta],
is_user_cell: &[bool],
width: u16,
) -> Vec<String> {
lines
.iter()
.enumerate()
.map(|(idx, line)| {
let is_user_row = line_meta
.get(idx)
.and_then(TranscriptLineMeta::cell_index)
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
.unwrap_or(false);
let mut merged_spans: Vec<ratatui::text::Span<'static>> = line
.spans
.iter()
.map(|span| ratatui::text::Span {
style: span.style.patch(line.style),
content: span.content.clone(),
})
.collect();
if is_user_row && width > 0 {
let text: String = merged_spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let text_width = UnicodeWidthStr::width(text.as_str());
let total_width = usize::from(width);
if text_width < total_width {
let pad_len = total_width.saturating_sub(text_width);
if pad_len > 0 {
let pad_style = crate::style::user_message_style();
merged_spans.push(ratatui::text::Span {
style: pad_style,
content: " ".repeat(pad_len).into(),
});
}
}
}
let mut buf: Vec<u8> = Vec::new();
let _ = crate::insert_history::write_spans(&mut buf, merged_spans.iter());
String::from_utf8(buf).unwrap_or_default()
})
.collect()
}
/// Apply the current transcript selection to the given buffer.
///
/// The selection is defined in terms of flattened wrapped transcript line
@@ -1401,13 +1316,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");
}
@@ -1476,6 +1391,7 @@ impl App {
};
self.chat_widget = ChatWidget::new(init, self.server.clone());
self.current_model = model_family.get_model_slug().to_string();
self.transcript_find.clear();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
@@ -1564,6 +1480,7 @@ impl App {
tui.frame_requester().schedule_frame();
}
self.transcript_cells.push(cell.clone());
self.transcript_find.note_lines_changed();
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
if !display.is_empty() {
// Only insert a separating blank line for new cells that are not
@@ -2011,6 +1928,11 @@ impl App {
}
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
if self.transcript_find.handle_key_event(&key_event) {
tui.frame_requester().schedule_frame();
return;
}
match key_event {
KeyEvent {
code: KeyCode::Char('t'),
@@ -2189,7 +2111,7 @@ mod tests {
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::new_session_info;
use crate::transcript_copy::CopySelectionShortcut;
use crate::transcript_copy_ui::CopySelectionShortcut;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ConversationManager;
@@ -2229,11 +2151,13 @@ mod tests {
transcript_cells: Vec::new(),
transcript_scroll: TranscriptScroll::default(),
transcript_selection: TranscriptSelection::default(),
transcript_multi_click: TranscriptMultiClick::default(),
transcript_view_top: 0,
transcript_total_lines: 0,
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
CopySelectionShortcut::CtrlShiftC,
),
transcript_find: TranscriptFind::default(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -2278,11 +2202,13 @@ mod tests {
transcript_cells: Vec::new(),
transcript_scroll: TranscriptScroll::default(),
transcript_selection: TranscriptSelection::default(),
transcript_multi_click: TranscriptMultiClick::default(),
transcript_view_top: 0,
transcript_total_lines: 0,
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
CopySelectionShortcut::CtrlShiftC,
),
transcript_find: TranscriptFind::default(),
overlay: None,
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
@@ -2363,10 +2289,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 +2694,12 @@ mod tests {
let is_user_cell = vec![true];
let width: u16 = 10;
let rendered = App::render_lines_to_ansi(&lines, &line_meta, &is_user_cell, width);
let rendered = crate::transcript_render::render_lines_to_ansi(
&lines,
&line_meta,
&is_user_cell,
width,
);
assert_eq!(rendered.len(), 1);
assert!(rendered[0].contains("hi"));
}

View File

@@ -124,6 +124,7 @@ pub(crate) struct ChatComposer {
transcript_selection_active: bool,
transcript_scroll_position: Option<(usize, usize)>,
transcript_copy_selection_key: KeyBinding,
transcript_find_visible: bool,
skills: Option<Vec<SkillMetadata>>,
dismissed_skill_popup_token: Option<String>,
}
@@ -176,6 +177,7 @@ impl ChatComposer {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
skills: None,
dismissed_skill_popup_token: None,
};
@@ -1545,6 +1547,7 @@ impl ChatComposer {
transcript_selection_active: self.transcript_selection_active,
transcript_scroll_position: self.transcript_scroll_position,
transcript_copy_selection_key: self.transcript_copy_selection_key,
transcript_find_visible: self.transcript_find_visible,
}
}
@@ -1577,11 +1580,13 @@ impl ChatComposer {
selection_active: bool,
scroll_position: Option<(usize, usize)>,
copy_selection_key: KeyBinding,
find_visible: bool,
) {
self.transcript_scrolled = scrolled;
self.transcript_selection_active = selection_active;
self.transcript_scroll_position = scroll_position;
self.transcript_copy_selection_key = copy_selection_key;
self.transcript_find_visible = find_visible;
}
fn sync_popups(&mut self) {

View File

@@ -26,6 +26,7 @@ pub(crate) struct FooterProps {
pub(crate) transcript_selection_active: bool,
pub(crate) transcript_scroll_position: Option<(usize, usize)>,
pub(crate) transcript_copy_selection_key: KeyBinding,
pub(crate) transcript_find_visible: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -119,6 +120,11 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
line.push_span(props.transcript_copy_selection_key);
line.push_span(" copy selection".dim());
}
if props.transcript_find_visible {
line.push_span(" · ".dim());
line.push_span(key_hint::ctrl(KeyCode::Char('g')));
line.push_span(" next match".dim());
}
vec![line]
}
FooterMode::ShortcutOverlay => {
@@ -187,6 +193,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut newline = Line::from("");
let mut file_paths = Line::from("");
let mut paste_image = Line::from("");
let mut find = Line::from("");
let mut find_next = Line::from("");
let mut edit_previous = Line::from("");
let mut quit = Line::from("");
let mut show_transcript = Line::from("");
@@ -198,6 +206,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
ShortcutId::InsertNewline => newline = text,
ShortcutId::FilePaths => file_paths = text,
ShortcutId::PasteImage => paste_image = text,
ShortcutId::Find => find = text,
ShortcutId::FindNext => find_next = text,
ShortcutId::EditPrevious => edit_previous = text,
ShortcutId::Quit => quit = text,
ShortcutId::ShowTranscript => show_transcript = text,
@@ -205,16 +215,8 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
}
}
let ordered = vec![
commands,
newline,
file_paths,
paste_image,
edit_previous,
quit,
Line::from(""),
show_transcript,
];
let mut ordered = vec![commands, newline, file_paths, paste_image, find, find_next];
ordered.extend(vec![edit_previous, quit, Line::from(""), show_transcript]);
build_columns(ordered)
}
@@ -286,6 +288,8 @@ enum ShortcutId {
InsertNewline,
FilePaths,
PasteImage,
Find,
FindNext,
EditPrevious,
Quit,
ShowTranscript,
@@ -406,6 +410,24 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
prefix: "",
label: " to paste images",
},
ShortcutDescriptor {
id: ShortcutId::Find,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('f')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to search transcript",
},
ShortcutDescriptor {
id: ShortcutId::FindNext,
bindings: &[ShortcutBinding {
key: key_hint::ctrl(KeyCode::Char('g')),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " next match",
},
ShortcutDescriptor {
id: ShortcutId::EditPrevious,
bindings: &[ShortcutBinding {
@@ -469,6 +491,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -485,6 +508,7 @@ mod tests {
transcript_selection_active: true,
transcript_scroll_position: Some((3, 42)),
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -501,6 +525,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -517,6 +542,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -533,6 +559,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -549,6 +576,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -565,6 +593,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -581,6 +610,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
@@ -597,6 +627,7 @@ mod tests {
transcript_selection_active: false,
transcript_scroll_position: None,
transcript_copy_selection_key: key_hint::ctrl_shift(KeyCode::Char('c')),
transcript_find_visible: false,
},
);
}

View File

@@ -387,12 +387,14 @@ impl BottomPane {
selection_active: bool,
scroll_position: Option<(usize, usize)>,
copy_selection_key: crate::key_hint::KeyBinding,
find_visible: bool,
) {
self.composer.set_transcript_ui_state(
scrolled,
selection_active,
scroll_position,
copy_selection_key,
find_visible,
);
self.request_redraw();
}

View File

@@ -12,5 +12,6 @@ expression: terminal.backend()
" "
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" ctrl + f to search transcript ctrl + g next match "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "

View File

@@ -4,5 +4,6 @@ expression: terminal.backend()
---
" / for commands shift + enter for newline "
" @ for file paths ctrl + v to paste images "
" ctrl + f to search transcript ctrl + g next match "
" esc again to edit previous message ctrl + c to exit "
" ctrl + t to view transcript "

View File

@@ -3097,12 +3097,14 @@ impl ChatWidget {
selection_active: bool,
scroll_position: Option<(usize, usize)>,
copy_selection_key: crate::key_hint::KeyBinding,
find_visible: bool,
) {
self.bottom_pane.set_transcript_ui_state(
scrolled,
selection_active,
scroll_position,
copy_selection_key,
find_visible,
);
}

View File

@@ -10,7 +10,6 @@ use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::text_formatting::format_and_truncate_tool_result;
@@ -21,7 +20,6 @@ use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_lines;
use base64::Engine;
use codex_common::format_env_display::format_env_display;
use codex_core::config::Config;
@@ -58,6 +56,47 @@ use std::time::Instant;
use tracing::error;
use unicode_width::UnicodeWidthStr;
/// Visual transcript lines plus soft-wrap joiners.
///
/// A history cell can produce multiple "visual lines" once prefixes/indents and wrapping are
/// applied. Clipboard reconstruction needs more information than just those lines: users expect
/// soft-wrapped prose to copy as a single logical line, while explicit newlines and spacer rows
/// should remain hard breaks.
///
/// `joiner_before` records, for each output line, whether it is a continuation created by the
/// wrapping algorithm and what string should be inserted at the wrap boundary when joining lines.
/// This avoids heuristics like always inserting a space, and instead preserves the exact whitespace
/// that was skipped at the boundary.
///
/// ## Note for `codex-tui` vs `codex-tui2`
///
/// In `codex-tui`, `HistoryCell` only exposes `transcript_lines(...)` and the UI generally doesn't
/// need to reconstruct clipboard text across off-screen history or soft-wrap boundaries.
///
/// In `codex-tui2`, transcript selection and copy are app-driven (not terminal-driven) and may span
/// content that isn't currently visible. That means we need additional metadata to distinguish hard
/// breaks from soft wraps and to preserve the exact whitespace at wrap boundaries.
///
/// Invariants:
/// - `joiner_before.len() == lines.len()`
/// - `joiner_before[0]` is always `None`
/// - `None` represents a hard break
/// - `Some(joiner)` represents a soft wrap continuation
///
/// Consumers:
/// - `transcript_render` threads joiners through transcript flattening/wrapping.
/// - `transcript_copy` uses them to join wrapped prose while preserving hard breaks.
#[derive(Debug, Clone)]
pub(crate) struct TranscriptLinesWithJoiners {
/// Visual transcript lines for a history cell, including any indent/prefix spans.
///
/// This is the same shape used for on-screen transcript rendering: a single cell may expand
/// to multiple `Line`s after wrapping and prefixing.
pub(crate) lines: Vec<Line<'static>>,
/// For each output line, whether and how to join it to the previous line when copying.
pub(crate) joiner_before: Vec<Option<String>>,
}
/// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list.
@@ -76,6 +115,19 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
self.display_lines(width)
}
/// Transcript lines plus soft-wrap joiners used for copy/paste fidelity.
///
/// Most cells can use the default implementation (no joiners), but cells that apply wrapping
/// should override this and return joiners derived from the same wrapping operation so
/// clipboard reconstruction can distinguish hard breaks from soft wraps.
fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
let lines = self.transcript_lines(width);
TranscriptLinesWithJoiners {
joiner_before: vec![None; lines.len()],
lines,
}
}
fn desired_transcript_height(&self, width: u16) -> u16 {
let lines = self.transcript_lines(width);
// Workaround for ratatui bug: if there's only one line and it's whitespace-only, ratatui gives 2 lines.
@@ -135,8 +187,10 @@ pub(crate) struct UserHistoryCell {
impl HistoryCell for UserHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
self.transcript_lines_with_joiners(width).lines
}
fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
let wrap_width = width
.saturating_sub(
LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */
@@ -145,17 +199,32 @@ impl HistoryCell for UserHistoryCell {
let style = user_message_style();
let wrapped = word_wrap_lines(
let (wrapped, joiner_before) = crate::wrapping::word_wrap_lines_with_joiners(
self.message.lines().map(|l| Line::from(l).style(style)),
// Wrap algorithm matches textarea.rs.
RtOptions::new(usize::from(wrap_width))
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit),
);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut joins: Vec<Option<String>> = Vec::new();
lines.push(Line::from("").style(style));
lines.extend(prefix_lines(wrapped, " ".bold().dim(), " ".into()));
joins.push(None);
let prefixed = prefix_lines(wrapped, " ".bold().dim(), " ".into());
for (line, joiner) in prefixed.into_iter().zip(joiner_before) {
lines.push(line);
joins.push(joiner);
}
lines.push(Line::from("").style(style));
lines
joins.push(None);
TranscriptLinesWithJoiners {
lines,
joiner_before: joins,
}
}
}
@@ -176,6 +245,10 @@ impl ReasoningSummaryCell {
}
fn lines(&self, width: u16) -> Vec<Line<'static>> {
self.lines_with_joiners(width).lines
}
fn lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
let mut lines: Vec<Line<'static>> = Vec::new();
append_markdown(
&self.content,
@@ -195,12 +268,17 @@ impl ReasoningSummaryCell {
})
.collect::<Vec<_>>();
word_wrap_lines(
let (lines, joiner_before) = crate::wrapping::word_wrap_lines_with_joiners(
&summary_lines,
RtOptions::new(width as usize)
.initial_indent("".dim().into())
.subsequent_indent(" ".into()),
)
);
TranscriptLinesWithJoiners {
lines,
joiner_before,
}
}
}
@@ -225,6 +303,10 @@ impl HistoryCell for ReasoningSummaryCell {
self.lines(width)
}
fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
self.lines_with_joiners(width)
}
fn desired_transcript_height(&self, width: u16) -> u16 {
self.lines(width).len() as u16
}
@@ -247,16 +329,50 @@ impl AgentMessageCell {
impl HistoryCell for AgentMessageCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
word_wrap_lines(
&self.lines,
RtOptions::new(width as usize)
.initial_indent(if self.is_first_line {
"".dim().into()
} else {
" ".into()
})
.subsequent_indent(" ".into()),
)
self.transcript_lines_with_joiners(width).lines
}
fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
use ratatui::style::Color;
let mut out_lines: Vec<Line<'static>> = Vec::new();
let mut joiner_before: Vec<Option<String>> = Vec::new();
let mut is_first_output_line = true;
for line in &self.lines {
let is_code_block_line = line.style.fg == Some(Color::Cyan);
let initial_indent: Line<'static> = if is_first_output_line && self.is_first_line {
"".dim().into()
} else {
" ".into()
};
let subsequent_indent: Line<'static> = " ".into();
if is_code_block_line {
let mut spans = initial_indent.spans;
spans.extend(line.spans.iter().cloned());
out_lines.push(Line::from(spans).style(line.style));
joiner_before.push(None);
is_first_output_line = false;
continue;
}
let opts = RtOptions::new(width as usize)
.initial_indent(initial_indent)
.subsequent_indent(subsequent_indent.clone());
let (wrapped, wrapped_joiners) =
crate::wrapping::word_wrap_line_with_joiners(line, opts);
for (l, j) in wrapped.into_iter().zip(wrapped_joiners) {
out_lines.push(line_to_static(&l));
joiner_before.push(j);
is_first_output_line = false;
}
}
TranscriptLinesWithJoiners {
lines: out_lines,
joiner_before,
}
}
fn is_stream_continuation(&self) -> bool {
@@ -358,21 +474,30 @@ impl PrefixedWrappedHistoryCell {
impl HistoryCell for PrefixedWrappedHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
if width == 0 {
return Vec::new();
}
let opts = RtOptions::new(width.max(1) as usize)
.initial_indent(self.initial_prefix.clone())
.subsequent_indent(self.subsequent_prefix.clone());
let wrapped = word_wrap_lines(&self.text, opts);
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
self.transcript_lines_with_joiners(width).lines
}
fn desired_height(&self, width: u16) -> u16 {
self.display_lines(width).len() as u16
}
fn transcript_lines_with_joiners(&self, width: u16) -> TranscriptLinesWithJoiners {
if width == 0 {
return TranscriptLinesWithJoiners {
lines: Vec::new(),
joiner_before: Vec::new(),
};
}
let opts = RtOptions::new(width.max(1) as usize)
.initial_indent(self.initial_prefix.clone())
.subsequent_indent(self.subsequent_prefix.clone());
let (lines, joiner_before) =
crate::wrapping::word_wrap_lines_with_joiners(&self.text, opts);
TranscriptLinesWithJoiners {
lines,
joiner_before,
}
}
}
fn truncate_exec_snippet(full_cmd: &str) -> String {

View File

@@ -1,3 +1,22 @@
//! Render `ratatui` transcript lines into terminal scrollback.
//!
//! `insert_history_lines` is responsible for inserting rendered transcript lines
//! *above* the TUI viewport by emitting ANSI control sequences through the
//! terminal backend writer.
//!
//! ## Why we use crossterm style commands
//!
//! `write_spans` is also used by non-terminal callers (e.g.
//! `transcript_render::render_lines_to_ansi`) to produce deterministic ANSI
//! output for tests and "print after exit" flows. That means the implementation
//! must work with any `impl Write` (including an in-memory `Vec<u8>`) and must
//! preserve `ratatui::style::Color` semantics, including `Rgb(...)` and
//! `Indexed(...)`.
//!
//! Crossterm's style commands implement `Command` (including ANSI emission), so
//! `write_spans` can remain backend-independent while still producing ANSI
//! output that matches the terminal-rendered transcript.
use std::fmt;
use std::io;
use std::io::Write;
@@ -10,14 +29,11 @@ use crossterm::style::Color as CColor;
use crossterm::style::Colors;
use crossterm::style::Print;
use crossterm::style::SetAttribute;
use crossterm::style::SetBackgroundColor;
use crossterm::style::SetColors;
use crossterm::style::SetForegroundColor;
use crossterm::terminal::Clear;
use crossterm::terminal::ClearType;
use ratatui::layout::Size;
use ratatui::prelude::Backend;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -97,14 +113,8 @@ where
queue!(
writer,
SetColors(Colors::new(
line.style
.fg
.map(std::convert::Into::into)
.unwrap_or(CColor::Reset),
line.style
.bg
.map(std::convert::Into::into)
.unwrap_or(CColor::Reset)
line.style.fg.map(Into::into).unwrap_or(CColor::Reset),
line.style.bg.map(Into::into).unwrap_or(CColor::Reset),
))
)?;
queue!(writer, Clear(ClearType::UntilNewLine))?;
@@ -245,8 +255,8 @@ pub(crate) fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io:
where
I: IntoIterator<Item = &'a Span<'a>>,
{
let mut fg = Color::Reset;
let mut bg = Color::Reset;
let mut fg = CColor::Reset;
let mut bg = CColor::Reset;
let mut last_modifier = Modifier::empty();
for span in content {
let mut modifier = Modifier::empty();
@@ -260,13 +270,10 @@ where
diff.queue(&mut writer)?;
last_modifier = modifier;
}
let next_fg = span.style.fg.unwrap_or(Color::Reset);
let next_bg = span.style.bg.unwrap_or(Color::Reset);
let next_fg = span.style.fg.map(Into::into).unwrap_or(CColor::Reset);
let next_bg = span.style.bg.map(Into::into).unwrap_or(CColor::Reset);
if next_fg != fg || next_bg != bg {
queue!(
writer,
SetColors(Colors::new(next_fg.into(), next_bg.into()))
)?;
queue!(writer, SetColors(Colors::new(next_fg, next_bg)))?;
fg = next_fg;
bg = next_bg;
}
@@ -274,12 +281,7 @@ where
queue!(writer, Print(span.content.clone()))?;
}
queue!(
writer,
SetForegroundColor(CColor::Reset),
SetBackgroundColor(CColor::Reset),
SetAttribute(crossterm::style::Attribute::Reset),
)
queue!(writer, SetAttribute(crossterm::style::Attribute::Reset))
}
#[cfg(test)]
@@ -287,8 +289,10 @@ mod tests {
use super::*;
use crate::markdown_render::render_markdown_text;
use crate::test_backend::VT100Backend;
use pretty_assertions::assert_eq;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
#[test]
fn writes_bold_then_regular_spans() {
@@ -306,8 +310,6 @@ mod tests {
Print("A"),
SetAttribute(crossterm::style::Attribute::NormalIntensity),
Print("B"),
SetForegroundColor(CColor::Reset),
SetBackgroundColor(CColor::Reset),
SetAttribute(crossterm::style::Attribute::Reset),
)
.unwrap();
@@ -318,6 +320,45 @@ mod tests {
);
}
#[test]
fn write_spans_emits_truecolor_and_indexed_sgr() {
// This test asserts that `write_spans` emits the correct SGR sequences for colors that
// can't be represented with the theme-aware ANSI palette:
//
// - `ratatui::style::Color::Rgb` (truecolor; `38;2;r;g;b`)
// - `ratatui::style::Color::Indexed` (256-color index; `48;5;n`)
//
// Those constructors are intentionally disallowed in production code (see
// `codex-rs/clippy.toml`), but the test needs them so the output bytes are fully
// deterministic.
#[expect(clippy::disallowed_methods)]
let fg = Color::Rgb(1, 2, 3);
#[expect(clippy::disallowed_methods)]
let bg = Color::Indexed(42);
let spans = [Span::styled("X", Style::default().fg(fg).bg(bg))];
let mut actual: Vec<u8> = Vec::new();
write_spans(&mut actual, spans.iter()).unwrap();
let mut expected: Vec<u8> = Vec::new();
queue!(
expected,
SetColors(Colors::new(
CColor::Rgb { r: 1, g: 2, b: 3 },
CColor::AnsiValue(42)
)),
Print("X"),
SetAttribute(crossterm::style::Attribute::Reset),
)
.unwrap();
assert_eq!(
String::from_utf8(actual).unwrap(),
String::from_utf8(expected).unwrap(),
);
}
#[test]
fn vt100_blockquote_line_emits_green_fg() {
// Set up a small off-screen terminal
@@ -331,7 +372,7 @@ mod tests {
// Build a blockquote-like line: apply line-level green style and prefix "> "
let mut line: Line<'static> = Line::from(vec!["> ".into(), "Hello world".into()]);
line = line.style(Color::Green);
line = line.style(Style::default().fg(Color::Green));
insert_history_lines(&mut term, vec![line])
.expect("Failed to insert history lines in test");
@@ -369,7 +410,7 @@ mod tests {
"> ".into(),
"This is a long quoted line that should wrap".into(),
]);
line = line.style(Color::Green);
line = line.style(Style::default().fg(Color::Green));
insert_history_lines(&mut term, vec![line])
.expect("Failed to insert history lines in test");

View File

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

View File

@@ -468,11 +468,19 @@ where
.indent_stack
.iter()
.any(|ctx| ctx.prefix.iter().any(|s| s.content.contains('>')));
let style = if blockquote_active {
let mut style = if blockquote_active {
self.styles.blockquote
} else {
line.style
};
// Code blocks are "preformatted": we want them to keep code styling even when they appear
// within other structures like blockquotes (which otherwise apply a line-level style).
//
// This matters for copy fidelity: downstream copy logic uses code styling as a cue to
// preserve indentation and to fence code runs with Markdown markers.
if self.in_code_block {
style = style.patch(self.styles.code);
}
let was_pending = self.pending_marker_line;
self.current_initial_indent = self.prefix_spans(was_pending);

View File

@@ -1,4 +1,5 @@
use pretty_assertions::assert_eq;
use ratatui::style::Color;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -382,34 +383,20 @@ fn blockquote_heading_inherits_heading_style() {
fn blockquote_with_code_block() {
let md = "> ```\n> code\n> ```\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["> code".to_string()]);
assert_eq!(text.lines, [Line::from_iter(["> ", "", "code"]).cyan()]);
}
#[test]
fn blockquote_with_multiline_code_block() {
let md = "> ```\n> first\n> second\n> ```\n";
let text = render_markdown_text(md);
let lines: Vec<String> = text
.lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>()
})
.collect();
assert_eq!(lines, vec!["> first", "> second"]);
assert_eq!(
text.lines,
[
Line::from_iter(["> ", "", "first"]).cyan(),
Line::from_iter(["> ", "", "second"]).cyan(),
]
);
}
#[test]
@@ -453,6 +440,12 @@ fn nested_blockquote_with_inline_and_fenced_code() {
"> > echo \"hello from a quote\"".to_string(),
]
);
// Fenced code inside nested blockquotes should keep code styling so copy logic can treat it as
// preformatted.
for idx in [4usize, 5usize] {
assert_eq!(text.lines[idx].style.fg, Some(Color::Cyan));
}
}
#[test]
@@ -654,7 +647,7 @@ fn link() {
#[test]
fn code_block_unhighlighted() {
let text = render_markdown_text("```rust\nfn main() {}\n```\n");
let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"])]);
let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"]).cyan()]);
assert_eq!(text, expected);
}
@@ -663,8 +656,8 @@ fn code_block_multiple_lines_root() {
let md = "```\nfirst\nsecond\n```\n";
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter(["", "first"]),
Line::from_iter(["", "second"]),
Line::from_iter(["", "first"]).cyan(),
Line::from_iter(["", "second"]).cyan(),
]);
assert_eq!(text, expected);
}
@@ -674,9 +667,9 @@ fn code_block_indented() {
let md = " function greet() {\n console.log(\"Hi\");\n }\n";
let text = render_markdown_text(md);
let expected = Text::from_iter([
Line::from_iter([" ", "function greet() {"]),
Line::from_iter([" ", " console.log(\"Hi\");"]),
Line::from_iter([" ", "}"]),
Line::from_iter([" ", "function greet() {"]).cyan(),
Line::from_iter([" ", " console.log(\"Hi\");"]).cyan(),
Line::from_iter([" ", "}"]).cyan(),
]);
assert_eq!(text, expected);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,332 @@
//! Transcript-selection copy UX helpers.
//!
//! # Background
//!
//! TUI2 owns a logical transcript viewport (with history that can live outside the visible buffer),
//! plus its own selection model. Terminal-native selection/copy does not work reliably in this
//! setup because:
//!
//! - The selection can extend outside the current viewport, while terminal selection can't.
//! - We want to exclude non-content regions (like the left gutter) from copied text.
//! - The terminal may intercept some keybindings before the app ever sees them.
//!
//! This module centralizes:
//!
//! - The effective "copy selection" shortcut (so the footer and affordance stay in sync).
//! - Key matching for triggering copy (with terminal quirks handled in one place).
//! - A small on-screen clickable "⧉ copy …" pill rendered near the current selection.
//!
//! # VS Code shortcut rationale
//!
//! VS Code's integrated terminal commonly captures `Ctrl+Shift+C` for its own copy behavior and
//! does not forward the keypress to applications running inside the terminal. Since we can't
//! observe it via crossterm, we advertise and accept `Ctrl+Y` in that environment.
//!
//! Clipboard text reconstruction (preserving indentation, joining soft-wrapped
//! prose, and emitting Markdown source markers) lives in `transcript_copy`.
use codex_core::terminal::TerminalName;
use codex_core::terminal::terminal_info;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
/// The shortcut we advertise and accept for "copy selection".
pub(crate) enum CopySelectionShortcut {
CtrlShiftC,
CtrlY,
}
/// Returns the best shortcut to advertise/accept for "copy selection".
///
/// VS Code's integrated terminal typically captures `Ctrl+Shift+C` for its own copy behavior and
/// does not forward it to applications running inside the terminal. That means we can't reliably
/// observe it via crossterm, so we use `Ctrl+Y` there.
///
/// We use both the terminal name (when available) and `VSCODE_IPC_HOOK_CLI` because the terminal
/// name can be `Unknown` early during startup in some environments.
pub(crate) fn detect_copy_selection_shortcut() -> CopySelectionShortcut {
let info = terminal_info();
if info.name == TerminalName::VsCode || std::env::var_os("VSCODE_IPC_HOOK_CLI").is_some() {
return CopySelectionShortcut::CtrlY;
}
CopySelectionShortcut::CtrlShiftC
}
pub(crate) fn key_binding_for(shortcut: CopySelectionShortcut) -> KeyBinding {
match shortcut {
CopySelectionShortcut::CtrlShiftC => key_hint::ctrl_shift(KeyCode::Char('c')),
CopySelectionShortcut::CtrlY => key_hint::ctrl(KeyCode::Char('y')),
}
}
/// Whether the given `(ch, modifiers)` should trigger "copy selection".
///
/// Terminal/event edge cases:
/// - Some terminals report `Ctrl+Shift+C` as `Char('C')` with `CONTROL` only, baking the shift into
/// the character. We accept both `c` and `C` in `CtrlShiftC` mode (including VS Code).
/// - Some environments intercept `Ctrl+Shift+C` before the app sees it. We keep `Ctrl+Y` as a
/// fallback in `CtrlShiftC` mode to preserve a working key path.
pub(crate) fn is_copy_selection_key(
shortcut: CopySelectionShortcut,
ch: char,
modifiers: KeyModifiers,
) -> bool {
if !modifiers.contains(KeyModifiers::CONTROL) {
return false;
}
match shortcut {
CopySelectionShortcut::CtrlY => ch == 'y' && modifiers == KeyModifiers::CONTROL,
CopySelectionShortcut::CtrlShiftC => {
(matches!(ch, 'c' | 'C') && (modifiers.contains(KeyModifiers::SHIFT) || ch == 'C'))
// Fallback for environments that intercept Ctrl+Shift+C.
|| (ch == 'y' && modifiers == KeyModifiers::CONTROL)
}
}
}
/// UI state for the on-screen copy affordance shown near an active selection.
///
/// This tracks a `Rect` for hit-testing so we can treat the pill as a clickable button.
#[derive(Debug)]
pub(crate) struct TranscriptCopyUi {
shortcut: CopySelectionShortcut,
dragging: bool,
affordance_rect: Option<Rect>,
}
impl TranscriptCopyUi {
/// Creates a new instance using the provided shortcut.
pub(crate) fn new_with_shortcut(shortcut: CopySelectionShortcut) -> Self {
Self {
shortcut,
dragging: false,
affordance_rect: None,
}
}
pub(crate) fn key_binding(&self) -> KeyBinding {
key_binding_for(self.shortcut)
}
pub(crate) fn is_copy_key(&self, ch: char, modifiers: KeyModifiers) -> bool {
is_copy_selection_key(self.shortcut, ch, modifiers)
}
pub(crate) fn set_dragging(&mut self, dragging: bool) {
self.dragging = dragging;
}
pub(crate) fn clear_affordance(&mut self) {
self.affordance_rect = None;
}
/// Returns `true` if the last rendered pill contains `(x, y)`.
///
/// `render_copy_pill()` sets `affordance_rect` and `clear_affordance()` clears it, so callers
/// should treat this as "hit test against the current frame's affordance".
pub(crate) fn hit_test(&self, x: u16, y: u16) -> bool {
self.affordance_rect
.is_some_and(|r| x >= r.x && x < r.right() && y >= r.y && y < r.bottom())
}
/// Render the copy "pill" just below the visible end of the selection.
///
/// Inputs are expressed in logical transcript coordinates:
/// - `anchor`/`head`: `(line_index, column)` in the wrapped transcript (not screen rows).
/// - `view_top`: first logical line index currently visible in `area`.
/// - `total_lines`: total number of logical transcript lines.
///
/// Placement details / edge cases:
/// - We hide the pill while dragging to avoid accidental clicks during selection updates.
/// - We only render if some part of the selection is visible, and there's room for a line
/// below it inside `area`.
/// - We scan the buffer to find the last non-space cell on each candidate row so the pill can
/// sit "near content", not far to the right past trailing whitespace.
///
/// Important: this assumes the transcript content has already been rendered into `buf` for the
/// current frame, since the placement logic derives `text_end` by inspecting buffer contents.
pub(crate) fn render_copy_pill(
&mut self,
area: Rect,
buf: &mut Buffer,
anchor: (usize, u16),
head: (usize, u16),
view_top: usize,
total_lines: usize,
) {
// Reset every frame. If we don't render (e.g. selection is off-screen) we shouldn't keep
// an old hit target around.
self.affordance_rect = None;
if self.dragging || total_lines == 0 {
return;
}
// Skip the transcript gutter (line numbers, diff markers, etc.). Selection/copy operates on
// transcript content only.
let base_x = area.x.saturating_add(TRANSCRIPT_GUTTER_COLS);
let max_x = area.right().saturating_sub(1);
if base_x > max_x {
return;
}
// Normalize to a start/end pair so the rest of the code can assume forward order.
let mut start = anchor;
let mut end = head;
if (end.0 < start.0) || (end.0 == start.0 && end.1 < start.1) {
std::mem::swap(&mut start, &mut end);
}
// We want to place the pill *near the visible end of the selection*, which means:
// - Find the last visible transcript line that intersects the selection.
// - Find the rightmost selected column on that line (clamped to actual rendered text).
// - Place the pill one row below that point.
let visible_start = view_top;
let visible_end = view_top
.saturating_add(area.height as usize)
.min(total_lines);
let mut last_visible_segment: Option<(u16, u16)> = None;
for (row_index, line_index) in (visible_start..visible_end).enumerate() {
// Skip lines outside the selection range.
if line_index < start.0 || line_index > end.0 {
continue;
}
let y = area.y + row_index as u16;
// Look for the rightmost non-space cell on this row so we can clamp the pill placement
// to real content. (The transcript renderer often pads the row with spaces.)
let mut last_text_x = None;
for x in base_x..=max_x {
let cell = &buf[(x, y)];
if cell.symbol() != " " {
last_text_x = Some(x);
}
}
let Some(text_end) = last_text_x else {
continue;
};
let line_end_col = if line_index == end.0 {
end.1
} else {
// For multi-line selections, treat intermediate lines as selected "to the end" so
// the pill doesn't jump left unexpectedly when only the final line has an explicit
// end column.
max_x.saturating_sub(base_x)
};
let row_sel_end = base_x.saturating_add(line_end_col).min(max_x);
if row_sel_end < base_x {
continue;
}
// Clamp the selection end to `text_end` so we don't place the pill far to the right on
// lines that are mostly blank (or padded).
let to_x = row_sel_end.min(text_end);
last_visible_segment = Some((y, to_x));
}
// If nothing in the selection is visible, don't show the affordance.
let Some((y, to_x)) = last_visible_segment else {
return;
};
// Place the pill on the row below the last visible selection segment.
let Some(y) = y.checked_add(1).filter(|y| *y < area.bottom()) else {
return;
};
let key_label: Span<'static> = self.key_binding().into();
let key_label = key_label.content.as_ref().to_string();
let pill_text = format!(" ⧉ copy {key_label} ");
let pill_width = UnicodeWidthStr::width(pill_text.as_str());
if pill_width == 0 || area.width == 0 {
return;
}
let pill_width = (pill_width as u16).min(area.width);
// Prefer a small gap between the selected content and the pill so we don't visually merge
// into the highlighted selection block.
let desired_x = to_x.saturating_add(2);
let max_start_x = area.right().saturating_sub(pill_width);
let x = if max_start_x < area.x {
area.x
} else {
desired_x.clamp(area.x, max_start_x)
};
let pill_area = Rect::new(x, y, pill_width, 1);
let base_style = Style::new().bg(Color::DarkGray);
let icon_style = base_style.fg(Color::Cyan);
let bold_style = base_style.add_modifier(Modifier::BOLD);
let mut spans: Vec<Span<'static>> = vec![
Span::styled(" ", base_style),
Span::styled("", icon_style),
Span::styled(" ", base_style),
Span::styled("copy", bold_style),
Span::styled(" ", base_style),
Span::styled(key_label, base_style),
];
spans.push(Span::styled(" ", base_style));
Paragraph::new(vec![Line::from(spans)]).render_ref(pill_area, buf);
self.affordance_rect = Some(pill_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Buffer;
fn buf_to_string(buf: &Buffer, area: Rect) -> String {
let mut s = String::new();
for y in area.y..area.bottom() {
for x in area.x..area.right() {
s.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
s.push('\n');
}
s
}
#[test]
fn ctrl_y_pill_does_not_include_ctrl_shift_c() {
let area = Rect::new(0, 0, 60, 3);
let mut buf = Buffer::empty(area);
for y in 0..area.height {
for x in 2..area.width.saturating_sub(1) {
buf[(x, y)].set_symbol("X");
}
}
let mut ui = TranscriptCopyUi::new_with_shortcut(CopySelectionShortcut::CtrlY);
ui.render_copy_pill(area, &mut buf, (1, 2), (1, 6), 0, 3);
let rendered = buf_to_string(&buf, area);
assert!(rendered.contains("copy"));
assert!(rendered.contains("ctrl + y"));
assert!(!rendered.contains("ctrl + shift + c"));
assert!(ui.affordance_rect.is_some());
}
}

View File

@@ -0,0 +1,889 @@
//! Inline "find in transcript" support for the TUI2 inline viewport.
//!
//! This module is intentionally UI-framework-light: it holds the find state and provides helpers
//! that `app.rs` calls from the normal render loop.
//!
//! **Integration points (in `tui2/src/app.rs`):**
//!
//! - Early key handling delegation via [`TranscriptFind::handle_key_event`]
//! - Per-frame update/jump hook via [`TranscriptFind::on_render`]
//! - Per-row highlight hook via [`TranscriptFind::render_line`]
//! - Prompt rendering + cursor positioning via [`TranscriptFind::render_prompt_line`] and
//! [`TranscriptFind::cursor_position`]
//!
//! The search operates on the flattened transcript lines (`Line<'static>`) that are already used
//! for scrolling and selection, and produces stable jump targets via
//! [`TranscriptLineMeta::cell_line`](TranscriptLineMeta::cell_line).
use crate::render::line_utils::line_to_static;
use crate::tui::scrolling::TranscriptLineMeta;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthStr as _;
use std::ops::Range;
#[derive(Debug, Default)]
/// Stateful "find in transcript" controller for the inline viewport.
///
/// The state is designed around the "ephemeral prompt" UX:
///
/// - `Ctrl-F` enters editing mode (prompt line becomes visible)
/// - `Esc` closes the prompt while keeping highlights (query remains)
/// - `Esc` again clears the query/highlights
/// - `Ctrl-G` advances to the next match while editing or while a query is still active
///
/// The caller is expected to:
///
/// - call [`TranscriptFind::on_render`] after flattening the transcript into `lines` + `line_meta`
/// - call [`TranscriptFind::render_line`] for visible rows
/// - use [`TranscriptFind::render_prompt_line`] + [`TranscriptFind::cursor_position`] when editing
pub(crate) struct TranscriptFind {
/// Current query text (plain UTF-8 string).
query: String,
/// Whether the prompt is currently visible and receiving input.
editing: bool,
/// Cached width of the flattened transcript viewport.
///
/// Width changes can change wrapping and therefore the flattened line list.
last_width: Option<u16>,
/// Cached flattened line count.
///
/// Note: callers should also invoke [`TranscriptFind::note_lines_changed`] when content changes
/// without changing the count (e.g., streaming updates).
last_lines_len: Option<usize>,
/// All matches across the flattened transcript, in display order (line-major, left-to-right).
matches: Vec<TranscriptFindMatch>,
/// Per-line mapping to indices into `matches`, used by `render_line`.
line_match_indices: Vec<Vec<usize>>,
/// Index into `matches` representing the current match (if any).
current_match: Option<usize>,
/// Stable identifier for the current match used to preserve selection across recompute.
///
/// `(line_index, match_range_start)`.
current_key: Option<(usize, usize)>,
/// A navigation action to apply on the next [`on_render`](TranscriptFind::on_render) call.
pending: Option<TranscriptFindPendingAction>,
}
#[derive(Debug, Clone, Copy)]
/// Deferred navigation requests applied at the next render.
///
/// This allows key events to be handled without directly mutating the scroll state; instead we
/// produce a stable anchor from [`TranscriptLineMeta`] during [`TranscriptFind::on_render`].
enum TranscriptFindPendingAction {
/// Jump to the "best" match for the current viewport (used after Enter).
Jump,
/// Advance the current match, wrapping around (used by Ctrl-G).
Next,
}
#[derive(Debug, Clone)]
/// One match within the flattened transcript.
///
/// Matches are stored in "scan order" (line-major, then left-to-right), and carry a stable anchor
/// for jumping the viewport.
struct TranscriptFindMatch {
line_index: usize,
range: Range<usize>,
/// Stable `(cell_index, line_in_cell)` anchor used to update scroll state.
anchor: Option<(usize, usize)>,
}
impl TranscriptFind {
/// Whether find is active (either editing or a non-empty query is present).
pub(crate) fn is_active(&self) -> bool {
self.editing || !self.query.is_empty()
}
/// Whether the find prompt should be visible.
pub(crate) fn is_visible(&self) -> bool {
self.editing
}
/// Force a recompute on the next render.
///
/// This is intended for "content changed but line count didn't" scenarios, such as streaming
/// assistant output updating in-place.
pub(crate) fn note_lines_changed(&mut self) {
if self.is_active() {
self.last_lines_len = None;
}
}
/// Handle find-related key events.
///
/// Returns `true` when the key was consumed by the find UI/state machine.
///
/// This method updates internal state only; it does not directly change the transcript scroll
/// position. Navigation keys (e.g. `Ctrl-G`, `Enter`) set a pending action that is applied on
/// the next [`on_render`](Self::on_render) call, where we can map the current match to a stable
/// `(cell_index, line_in_cell)` anchor.
pub(crate) fn handle_key_event(&mut self, key_event: &KeyEvent) -> bool {
match *key_event {
KeyEvent {
code: KeyCode::Char('f'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.begin_edit();
true
}
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} if self.editing => {
self.end_edit();
true
}
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} if !self.query.is_empty() => {
self.clear();
true
}
KeyEvent {
code: KeyCode::Char('g'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} if self.editing || !self.query.is_empty() => {
self.set_pending(TranscriptFindPendingAction::Next);
true
}
_ if self.editing => {
self.handle_edit_key(*key_event);
true
}
_ => false,
}
}
/// Cursor position for the prompt line (when editing).
///
/// The caller passes the full frame `area` and the computed top of the chat widget (`chat_top`)
/// so we can position the cursor on the line directly above the composer.
pub(crate) fn cursor_position(&self, area: Rect, chat_top: u16) -> Option<(u16, u16)> {
if !self.editing || chat_top <= area.y {
return None;
}
let prefix_w = "/ ".width() as u16;
let query_w = self.query.width() as u16;
let x = area
.x
.saturating_add(prefix_w)
.saturating_add(query_w)
.min(area.right().saturating_sub(1));
let y = chat_top.saturating_sub(1);
Some((x, y))
}
/// Update match state and apply any pending navigation request.
///
/// Returns a stable transcript anchor `(cell_index, line_in_cell)` to be consumed by the
/// transcript scroll state (e.g. to jump the viewport to the current match).
///
/// Returns `None` when:
/// - find is inactive, or
/// - no navigation action is pending, or
/// - the current match does not map to a stable anchor (e.g. no `CellLine` meta).
pub(crate) fn on_render(
&mut self,
lines: &[Line<'static>],
line_meta: &[TranscriptLineMeta],
width: u16,
preferred_line: usize,
) -> Option<(usize, usize)> {
if !self.is_active() {
return None;
}
self.ensure_up_to_date(lines, line_meta, width, preferred_line);
self.apply_pending(preferred_line)
}
/// Render a transcript line with match highlighting (if active).
///
/// Highlighting is computed on the flattened plain text of the line (see [`line_plain_text`])
/// and then applied back onto the styled spans by splitting and patching styles.
///
/// Styling conventions:
/// - All matches: underlined
/// - Current match: reversed + bold + underlined
pub(crate) fn render_line(&self, line_index: usize, line: &Line<'_>) -> Line<'static> {
if self.query.is_empty() {
return line_to_static(line);
}
let indices = self.match_indices_for_line(line_index);
if indices.is_empty() {
return line_to_static(line);
}
let mut ranges: Vec<(Range<usize>, Style)> = Vec::with_capacity(indices.len());
for idx in indices {
let m = &self.matches[*idx];
let style = if self.current_match == Some(*idx) {
Style::new().reversed().bold().underlined()
} else {
Style::new().underlined()
};
ranges.push((m.range.clone(), style));
}
highlight_line(line, &ranges)
}
/// Render the prompt row shown above the composer while editing.
pub(crate) fn render_prompt_line(&self) -> Option<Line<'static>> {
if !self.editing {
return None;
}
let (current, total) = self.match_summary();
let mut spans: Vec<Span<'static>> = vec!["/ ".dim()];
spans.push(self.query.clone().into());
if !self.query.is_empty() {
spans.push(format!(" {current}/{total}").dim());
}
Some(Line::from(spans))
}
/// Handle key events while editing the query.
///
/// This is only called when [`Self::editing`] is true and the event wasn't handled by the
/// top-level key bindings (e.g., `Ctrl-F`, `Ctrl-G`, `Esc`).
fn handle_edit_key(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
} => {
self.editing = false;
self.set_pending(TranscriptFindPendingAction::Jump);
}
KeyEvent {
code: KeyCode::Char('u'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.clear_query();
}
KeyEvent {
code: KeyCode::Backspace,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.backspace();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} if !crate::key_hint::has_ctrl_or_alt(modifiers) => {
self.push_char(c);
}
_ => {}
}
}
/// Enter editing mode (idempotent).
fn begin_edit(&mut self) {
if self.editing {
return;
}
self.editing = true;
}
/// Exit editing mode without clearing the query/highlights.
fn end_edit(&mut self) {
self.editing = false;
}
/// Clear all state, including query/highlights and cached match results.
pub(crate) fn clear(&mut self) {
self.query.clear();
self.editing = false;
self.last_width = None;
self.last_lines_len = None;
self.matches.clear();
self.line_match_indices.clear();
self.current_match = None;
self.current_key = None;
self.pending = None;
}
/// Clear the query text while leaving editing mode unchanged.
///
/// This resets the cached width so matches will be recomputed when the user types again.
fn clear_query(&mut self) {
self.query.clear();
self.last_width = None;
}
/// Remove a single character from the end of the query.
fn backspace(&mut self) {
if self.query.pop().is_some() {
self.last_width = None;
}
}
/// Append one character to the query.
fn push_char(&mut self, ch: char) {
self.query.push(ch);
self.last_width = None;
}
/// Record a navigation request to be applied on the next render.
fn set_pending(&mut self, pending: TranscriptFindPendingAction) {
self.pending = Some(pending);
}
/// Recompute matches when the flattened transcript representation changes.
///
/// This function is keyed on `width` and `lines.len()` to avoid work on every frame; callers
/// should invoke [`TranscriptFind::note_lines_changed`] if content changes without affecting
/// those keys.
///
/// When recomputing:
/// - we preserve the selected match using [`Self::current_key`]
/// - we store a stable jump anchor (cell + line-in-cell) when available via
/// [`TranscriptLineMeta::cell_line`]
fn ensure_up_to_date(
&mut self,
lines: &[Line<'static>],
line_meta: &[TranscriptLineMeta],
width: u16,
preferred_line: usize,
) {
// Fast path: empty query means there are no matches, and we should treat find as inactive
// (even if `editing` is still true).
if self.query.is_empty() {
self.matches.clear();
self.line_match_indices.clear();
self.current_match = None;
self.current_key = None;
self.last_width = Some(width);
self.last_lines_len = Some(lines.len());
return;
}
// Cache key: if width and line count are unchanged, the flattened transcript representation
// is assumed stable enough to reuse the previous match computation. Callers should use
// `note_lines_changed()` when content changes without affecting these keys.
if self.last_width == Some(width) && self.last_lines_len == Some(lines.len()) {
return;
}
// Preserve selection across recompute by remembering the currently-selected match's
// identity (line index + start byte offset in the flattened plain text).
let current_key = self.current_key.take();
self.matches.clear();
self.line_match_indices = vec![Vec::new(); lines.len()];
// Scan each flattened line, extracting plain text and recording match ranges. We keep:
// - a global `matches` list in scan order for navigation (Ctrl-G)
// - a per-line index list for rendering highlights efficiently
for (line_index, line) in lines.iter().enumerate() {
let plain = line_plain_text(line);
let ranges = find_match_ranges(&plain, &self.query);
for range in ranges {
let idx = self.matches.len();
// Only `CellLine` entries have stable anchors suitable for scroll jumps.
let anchor = line_meta
.get(line_index)
.and_then(TranscriptLineMeta::cell_line);
self.matches.push(TranscriptFindMatch {
line_index,
range: range.clone(),
anchor,
});
self.line_match_indices[line_index].push(idx);
}
}
// Choose the current match:
// 1) Prefer to keep the previous selection if it still exists in the new match set.
// 2) Otherwise, pick the first match at/after the preferred line (top of the viewport).
// 3) Otherwise, wrap to the first match.
self.current_match = current_key
.and_then(|(line_index, start)| {
self.matches
.iter()
.position(|m| m.line_index == line_index && m.range.start == start)
})
.or_else(|| {
self.matches
.iter()
.position(|m| m.line_index >= preferred_line)
.or_else(|| (!self.matches.is_empty()).then_some(0))
});
self.current_key = self.current_match.map(|i| {
let m = &self.matches[i];
(m.line_index, m.range.start)
});
self.last_width = Some(width);
self.last_lines_len = Some(lines.len());
}
/// Apply a pending navigation action (if any) and return the anchor to scroll to.
///
/// The returned `(cell_index, line_in_cell)` is derived from [`TranscriptLineMeta`] and is used
/// by the caller to update the transcript scroll state.
///
/// Note: the internal "current match" is updated even when an anchor is unavailable; in that
/// case this returns `None` and the caller should not change scroll position.
fn apply_pending(&mut self, preferred_line: usize) -> Option<(usize, usize)> {
let pending = self.pending.take()?;
if self.matches.is_empty() {
self.current_match = None;
self.current_key = None;
return None;
}
match pending {
TranscriptFindPendingAction::Jump => {
if self.current_match.is_none() {
self.current_match = self
.matches
.iter()
.position(|m| m.line_index >= preferred_line)
.or_else(|| (!self.matches.is_empty()).then_some(0));
}
}
TranscriptFindPendingAction::Next => {
self.current_match = Some(match self.current_match {
Some(i) => (i + 1) % self.matches.len(),
None => 0,
});
}
}
self.current_key = self.current_match.map(|i| {
let m = &self.matches[i];
(m.line_index, m.range.start)
});
self.current_match.and_then(|i| self.matches[i].anchor)
}
/// Return indices into `matches` for the given flattened line index.
///
/// Out-of-range indices return an empty slice to simplify call sites.
fn match_indices_for_line(&self, line_index: usize) -> &[usize] {
self.line_match_indices
.get(line_index)
.map_or(&[], |v| v.as_slice())
}
/// Return `(current, total)` match counts for the prompt display.
///
/// `current` is 1-based (`0` when no match is selected), and `total` is the number of matches.
fn match_summary(&self) -> (usize, usize) {
let total = self.matches.len();
let current = self.current_match.map(|i| i + 1).unwrap_or(0);
(current, total)
}
}
/// Convert a styled [`Line`] into plain text by concatenating its spans.
///
/// Find operates on plain text for match computation. Styling is applied later during
/// [`highlight_line`].
fn line_plain_text(line: &Line<'_>) -> String {
let mut out = String::new();
for span in &line.spans {
out.push_str(span.content.as_ref());
}
out
}
/// Find all non-overlapping match ranges of `needle` within `haystack`.
///
/// Uses "smart-case" semantics:
/// - If `needle` contains any ASCII uppercase letters, matching is case-sensitive.
/// - Otherwise, both strings are compared in ASCII lowercase.
fn find_match_ranges(haystack: &str, needle: &str) -> Vec<Range<usize>> {
if needle.is_empty() {
return Vec::new();
}
let is_case_sensitive = needle.chars().any(|c| c.is_ascii_uppercase());
if is_case_sensitive {
find_match_ranges_exact(haystack, needle)
} else {
let haystack = haystack.to_ascii_lowercase();
let needle = needle.to_ascii_lowercase();
find_match_ranges_exact(&haystack, &needle)
}
}
/// Find all non-overlapping match ranges of `needle` within `haystack` (case-sensitive).
///
/// Ranges are byte indices into the provided `haystack` string.
fn find_match_ranges_exact(haystack: &str, needle: &str) -> Vec<Range<usize>> {
let mut out = Vec::new();
let mut start = 0usize;
while start <= haystack.len() {
let Some(rel) = haystack[start..].find(needle) else {
break;
};
let abs = start + rel;
let end = abs + needle.len();
out.push(abs..end);
start = end;
}
out
}
/// Apply highlighting styles to the provided `line`.
///
/// `ranges` is a list of `(byte_range, style)` pairs referring to byte offsets in the flattened
/// plain text of `line` (see [`line_plain_text`]). The implementation preserves existing span
/// styles by patching the requested highlight style onto each affected segment.
fn highlight_line(line: &Line<'_>, ranges: &[(Range<usize>, Style)]) -> Line<'static> {
if ranges.is_empty() {
return line_to_static(line);
}
// We treat `line` as a single flattened string (see `line_plain_text`) and map highlight byte
// ranges back onto the individual styled spans. As we walk spans left-to-right we keep a
// "global" byte cursor into the flattened text plus an index into `ranges`.
let mut out: Vec<Span<'static>> = Vec::new();
let mut global_pos = 0usize;
let mut range_idx = 0usize;
for span in &line.spans {
let text = span.content.as_ref();
let span_start = global_pos;
let span_end = span_start + text.len();
global_pos = span_end;
// Skip ranges that end before this span begins. This keeps `range_idx` pointing at the
// first range that could possibly overlap the current span.
while range_idx < ranges.len() && ranges[range_idx].0.end <= span_start {
range_idx += 1;
}
// `local_pos` tracks how much of the current span we've emitted. We may need to split the
// span into multiple segments as highlight ranges start/end within it.
let mut local_pos = 0usize;
while range_idx < ranges.len() {
let (range, extra_style) = &ranges[range_idx];
if range.start >= span_end {
break;
}
// Clamp the global match range to this span's global extent.
let start = range.start.max(span_start);
let end = range.end.min(span_end);
let start_local = start - span_start;
// Emit any unhighlighted segment that appears before the match starts.
if start_local > local_pos {
out.push(Span::styled(
text[local_pos..start_local].to_string(),
span.style,
));
}
let end_local = end - span_start;
// Emit the highlighted segment, preserving existing style by patching it with the
// requested highlight style.
out.push(Span::styled(
text[start_local..end_local].to_string(),
span.style.patch(*extra_style),
));
local_pos = end_local;
// Advance to the next range when we've consumed the entire range within this span.
// When the range extends past this span, we keep `range_idx` pinned and continue the
// highlighting into the next span.
if range.end <= span_end {
range_idx += 1;
} else {
break;
}
}
// Emit any remaining tail of the span that doesn't intersect a highlight range.
if local_pos < text.len() {
out.push(Span::styled(text[local_pos..].to_string(), span.style));
}
}
Line::from(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
/// Build `TranscriptLineMeta` for a single-cell transcript with `line_count` lines.
fn meta_cell_lines(line_count: usize) -> Vec<TranscriptLineMeta> {
(0..line_count)
.map(|line_in_cell| TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell,
})
.collect()
}
/// Convenience for a key press with no modifiers.
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
/// Convenience for a key press with the control modifier.
fn ctrl(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::CONTROL)
}
#[test]
fn smart_case_and_pending_jump() {
let lines: Vec<Line<'static>> = vec![Line::from("hello World"), Line::from("second world")];
let meta = vec![
TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 0,
},
TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 1,
},
];
let mut find = TranscriptFind {
query: "world".to_string(),
..Default::default()
};
assert_eq!(find.on_render(&lines, &meta, 80, 0), None);
assert_eq!(find.matches.len(), 2);
find.current_match = None;
find.current_key = None;
find.pending = Some(TranscriptFindPendingAction::Jump);
let anchor = find.on_render(&lines, &meta, 80, 1);
assert_eq!(anchor, Some((0, 1)));
find.clear();
find.query = "World".to_string();
let _ = find.on_render(&lines, &meta, 80, 0);
assert_eq!(
find.matches
.iter()
.map(|m| m.line_index)
.collect::<Vec<_>>(),
vec![0]
);
}
#[test]
fn ctrl_f_enters_editing_and_renders_prompt() {
let mut find = TranscriptFind::default();
assert!(!find.is_active());
assert!(find.handle_key_event(&ctrl(KeyCode::Char('f'))));
assert!(find.is_active());
assert!(find.is_visible());
let prompt = find.render_prompt_line().expect("prompt line");
assert_eq!(
prompt
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<Vec<_>>(),
vec!["/ ", ""]
);
}
#[test]
fn edit_keys_modify_query() {
let mut find = TranscriptFind::default();
let _ = find.handle_key_event(&ctrl(KeyCode::Char('f')));
let _ = find.handle_key_event(&key(KeyCode::Char('a')));
let _ = find.handle_key_event(&key(KeyCode::Char('b')));
assert_eq!(find.query, "ab");
let _ = find.handle_key_event(&key(KeyCode::Backspace));
assert_eq!(find.query, "a");
let _ = find.handle_key_event(&ctrl(KeyCode::Char('u')));
assert_eq!(find.query, "");
}
#[test]
fn enter_closes_prompt_and_requests_jump() {
let lines: Vec<Line<'static>> = vec![Line::from("one two"), Line::from("two three")];
let meta = meta_cell_lines(lines.len());
let mut find = TranscriptFind::default();
let _ = find.handle_key_event(&ctrl(KeyCode::Char('f')));
for ch in "two".chars() {
let _ = find.handle_key_event(&key(KeyCode::Char(ch)));
}
let _ = find.handle_key_event(&key(KeyCode::Enter));
assert!(!find.is_visible());
assert!(find.is_active());
assert_eq!(find.render_prompt_line(), None);
let anchor = find.on_render(&lines, &meta, 80, 0);
assert_eq!(anchor, Some((0, 0)));
}
#[test]
fn esc_closes_prompt_then_clears_query() {
let mut find = TranscriptFind::default();
let _ = find.handle_key_event(&ctrl(KeyCode::Char('f')));
for ch in "two".chars() {
let _ = find.handle_key_event(&key(KeyCode::Char(ch)));
}
let _ = find.handle_key_event(&key(KeyCode::Esc));
assert!(!find.is_visible());
assert!(find.is_active());
assert_eq!(find.query, "two");
let _ = find.handle_key_event(&key(KeyCode::Esc));
assert!(!find.is_active());
assert_eq!(find.query, "");
}
#[test]
fn ctrl_g_cycles_matches_after_prompt_closed() {
let lines: Vec<Line<'static>> =
vec![Line::from("world world"), Line::from("another world")];
let meta = meta_cell_lines(lines.len());
let mut find = TranscriptFind::default();
let _ = find.handle_key_event(&ctrl(KeyCode::Char('f')));
for ch in "world".chars() {
let _ = find.handle_key_event(&key(KeyCode::Char(ch)));
}
let _ = find.handle_key_event(&key(KeyCode::Enter));
let a0 = find.on_render(&lines, &meta, 80, 0);
assert_eq!(a0, Some((0, 0)));
assert!(find.handle_key_event(&ctrl(KeyCode::Char('g'))));
let a1 = find.on_render(&lines, &meta, 80, 0);
assert_eq!(a1, Some((0, 0)));
assert!(find.handle_key_event(&ctrl(KeyCode::Char('g'))));
let a2 = find.on_render(&lines, &meta, 80, 0);
assert_eq!(a2, Some((0, 1)));
assert!(find.handle_key_event(&ctrl(KeyCode::Char('g'))));
let a3 = find.on_render(&lines, &meta, 80, 0);
assert_eq!(a3, Some((0, 0)));
}
#[test]
fn note_lines_changed_forces_recompute() {
let mut find = TranscriptFind {
query: "hello".to_string(),
..Default::default()
};
let lines_v1 = vec![Line::from("hello")];
let meta = meta_cell_lines(lines_v1.len());
let _ = find.on_render(&lines_v1, &meta, 80, 0);
assert_eq!(find.matches.len(), 1);
find.note_lines_changed();
assert_eq!(find.last_lines_len, None);
let lines_v2 = vec![Line::from("world")];
let _ = find.on_render(&lines_v2, &meta, 80, 0);
assert_eq!(find.matches.len(), 0);
}
#[test]
fn render_line_highlights_current_match_more_strongly() {
let lines = vec![Line::from("aa")];
let meta = meta_cell_lines(lines.len());
let mut find = TranscriptFind {
query: "a".to_string(),
..Default::default()
};
let _ = find.on_render(&lines, &meta, 80, 0);
let rendered = find.render_line(0, &lines[0]);
assert_eq!(
rendered,
Line::from(vec![
Span::styled("a", Style::new().reversed().bold().underlined()),
Span::styled("a", Style::new().underlined()),
])
);
}
#[test]
fn highlight_line_supports_ranges_across_span_boundaries() {
let line = Line::from(vec!["hello ".into(), "world".into()]);
let query = "o w";
let ranges = find_match_ranges(&line_plain_text(&line), query);
assert_eq!(ranges, vec![4..7]);
let rendered = highlight_line(&line, &[(ranges[0].clone(), Style::new().underlined())]);
assert_eq!(
rendered,
Line::from(vec![
Span::styled("hell".to_string(), Style::default()),
Span::styled("o ".to_string(), Style::new().underlined()),
Span::styled("w".to_string(), Style::new().underlined()),
Span::styled("orld".to_string(), Style::default()),
])
);
}
#[test]
fn cursor_position_clamps_to_prompt_line_width() {
let mut find = TranscriptFind {
query: "abcdef".to_string(),
editing: true,
..Default::default()
};
let area = Rect::new(10, 5, 5, 5);
let (x, y) = find.cursor_position(area, 8).expect("cursor");
assert_eq!(y, 7);
assert_eq!(x, area.right().saturating_sub(1));
find.clear();
}
#[test]
fn ctrl_g_is_ignored_when_inactive() {
let mut find = TranscriptFind::default();
assert!(!find.handle_key_event(&ctrl(KeyCode::Char('g'))));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,399 @@
//! Transcript rendering helpers (flattening, wrapping, and metadata).
//!
//! `App` treats the transcript (history cells) as the source of truth and
//! renders a *flattened* list of visual lines into the viewport. A single
//! history cell may render multiple visual lines, and the viewport may include
//! synthetic spacer rows between cells.
//!
//! This module centralizes the logic for:
//! - Flattening history cells into visual `ratatui::text::Line`s.
//! - Producing parallel metadata (`TranscriptLineMeta`) used for scroll
//! anchoring and "user row" styling.
//! - Computing *soft-wrap joiners* so copy can treat wrapped prose as one
//! logical line instead of inserting hard newlines.
use crate::history_cell::HistoryCell;
use crate::tui::scrolling::TranscriptLineMeta;
use ratatui::text::Line;
use std::sync::Arc;
/// Flattened transcript lines plus the metadata required to interpret them.
#[derive(Debug)]
pub(crate) struct TranscriptLines {
/// Flattened visual transcript lines, in the same order they are rendered.
pub(crate) lines: Vec<Line<'static>>,
/// Parallel metadata for each line (same length as `lines`).
///
/// This maps a visual line back to `(cell_index, line_in_cell)` so scroll
/// anchoring and "user row" styling remain stable across reflow.
pub(crate) meta: Vec<TranscriptLineMeta>,
/// Soft-wrap joiners (same length as `lines`).
///
/// `joiner_before[i]` is `Some(joiner)` when line `i` is a soft-wrap
/// continuation of line `i - 1`, and `None` when the break is a hard break
/// (between input lines/cells, or spacer rows).
///
/// Copy uses this to join wrapped prose without inserting hard newlines,
/// while still preserving hard line breaks and explicit blank lines.
pub(crate) joiner_before: Vec<Option<String>>,
}
/// Build flattened transcript lines without applying additional viewport wrapping.
///
/// This is useful for:
/// - Exit transcript rendering (ANSI) where we want the "cell as rendered"
/// output.
/// - Any consumer that wants a stable cell → line mapping without re-wrapping.
pub(crate) fn build_transcript_lines(
cells: &[Arc<dyn HistoryCell>],
width: u16,
) -> TranscriptLines {
// This function is the "lossless" transcript flattener:
// - it asks each cell for its transcript lines (including any per-cell prefixes/indents)
// - it inserts spacer rows between non-continuation cells to match the viewport layout
// - it emits parallel metadata so scroll anchoring can map visual lines back to cells.
let mut lines: Vec<Line<'static>> = Vec::new();
let mut meta: Vec<TranscriptLineMeta> = Vec::new();
let mut joiner_before: Vec<Option<String>> = Vec::new();
let mut has_emitted_lines = false;
for (cell_index, cell) in cells.iter().enumerate() {
// Cells provide joiners alongside lines so copy can distinguish hard breaks from soft wraps
// (and preserve the exact whitespace at wrap boundaries).
let rendered = cell.transcript_lines_with_joiners(width);
if rendered.lines.is_empty() {
continue;
}
// Cells that are not stream continuations are separated by an explicit spacer row.
// This keeps the flattened transcript aligned with what the user sees in the viewport
// and preserves intentional blank lines in copy.
if !cell.is_stream_continuation() {
if has_emitted_lines {
lines.push(Line::from(""));
meta.push(TranscriptLineMeta::Spacer);
joiner_before.push(None);
} else {
has_emitted_lines = true;
}
}
for (line_in_cell, line) in rendered.lines.into_iter().enumerate() {
// `line_in_cell` is the *visual* line index within the cell. Consumers use this for
// anchoring (e.g., "keep this row visible when the transcript reflows").
meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell,
});
lines.push(line);
// Maintain the `joiner_before` invariant: exactly one entry per output line.
joiner_before.push(
rendered
.joiner_before
.get(line_in_cell)
.cloned()
.unwrap_or(None),
);
}
}
TranscriptLines {
lines,
meta,
joiner_before,
}
}
/// Build flattened transcript lines as they appear in the transcript viewport.
///
/// This applies *viewport wrapping* to prose lines, while deliberately avoiding
/// wrapping for preformatted content (currently detected via the code-block
/// line style) so indentation remains meaningful for copy/paste.
pub(crate) fn build_wrapped_transcript_lines(
cells: &[Arc<dyn HistoryCell>],
width: u16,
) -> TranscriptLines {
use crate::render::line_utils::line_to_static;
use ratatui::style::Color;
if width == 0 {
return TranscriptLines {
lines: Vec::new(),
meta: Vec::new(),
joiner_before: Vec::new(),
};
}
let base_opts: crate::wrapping::RtOptions<'_> =
crate::wrapping::RtOptions::new(width.max(1) as usize);
let mut lines: Vec<Line<'static>> = Vec::new();
let mut meta: Vec<TranscriptLineMeta> = Vec::new();
let mut joiner_before: Vec<Option<String>> = Vec::new();
let mut has_emitted_lines = false;
for (cell_index, cell) in cells.iter().enumerate() {
// Start from each cell's transcript view (prefixes/indents already applied), then apply
// viewport wrapping to prose while keeping preformatted content intact.
let rendered = cell.transcript_lines_with_joiners(width);
if rendered.lines.is_empty() {
continue;
}
if !cell.is_stream_continuation() {
if has_emitted_lines {
lines.push(Line::from(""));
meta.push(TranscriptLineMeta::Spacer);
joiner_before.push(None);
} else {
has_emitted_lines = true;
}
}
// `visual_line_in_cell` counts the output visual lines produced from this cell *after* any
// viewport wrapping. This is distinct from `base_idx` (the index into the cell's input
// lines), since a single input line may wrap into multiple visual lines.
let mut visual_line_in_cell: usize = 0;
let mut first = true;
for (base_idx, base_line) in rendered.lines.iter().enumerate() {
// Preserve code blocks (and other preformatted text) by not applying
// viewport wrapping, so indentation remains meaningful for copy/paste.
if base_line.style.fg == Some(Color::Cyan) {
lines.push(base_line.clone());
meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell: visual_line_in_cell,
});
visual_line_in_cell = visual_line_in_cell.saturating_add(1);
// Preformatted lines are treated as hard breaks; we keep the cell-provided joiner
// (which is typically `None`).
joiner_before.push(
rendered
.joiner_before
.get(base_idx)
.cloned()
.unwrap_or(None),
);
first = false;
continue;
}
let opts = if first {
base_opts.clone()
} else {
// For subsequent input lines within a cell, treat the "initial" indent as the
// cell's subsequent indent (matches textarea wrapping expectations).
base_opts
.clone()
.initial_indent(base_opts.subsequent_indent.clone())
};
// `word_wrap_line_with_joiners` returns both the wrapped visual lines and, for each
// continuation segment, the exact joiner substring that should be inserted instead of a
// newline when copying as a logical line.
let (wrapped, wrapped_joiners) =
crate::wrapping::word_wrap_line_with_joiners(base_line, opts);
for (seg_idx, (wrapped_line, seg_joiner)) in
wrapped.into_iter().zip(wrapped_joiners).enumerate()
{
lines.push(line_to_static(&wrapped_line));
meta.push(TranscriptLineMeta::CellLine {
cell_index,
line_in_cell: visual_line_in_cell,
});
visual_line_in_cell = visual_line_in_cell.saturating_add(1);
if seg_idx == 0 {
// The first wrapped segment corresponds to the original input line, so we use
// the cell-provided joiner (hard break vs soft break *between input lines*).
joiner_before.push(
rendered
.joiner_before
.get(base_idx)
.cloned()
.unwrap_or(None),
);
} else {
// Subsequent wrapped segments are soft-wrap continuations produced by viewport
// wrapping, so we use the wrap-derived joiner.
joiner_before.push(seg_joiner);
}
}
first = false;
}
}
TranscriptLines {
lines,
meta,
joiner_before,
}
}
/// Render flattened transcript lines into ANSI strings suitable for printing after the TUI exits.
///
/// This helper mirrors the transcript viewport behavior:
/// - Merges line-level style into each span so ANSI output matches on-screen styling.
/// - For user-authored rows, pads the background style out to the full terminal width so prompts
/// appear as solid blocks in scrollback.
/// - Streams spans through the shared vt100 writer so downstream tests and tools see consistent
/// escape sequences.
pub(crate) fn render_lines_to_ansi(
lines: &[Line<'static>],
line_meta: &[TranscriptLineMeta],
is_user_cell: &[bool],
width: u16,
) -> Vec<String> {
use unicode_width::UnicodeWidthStr;
lines
.iter()
.enumerate()
.map(|(idx, line)| {
// Determine whether this visual line belongs to a user-authored cell. We use this to
// pad the background to the full terminal width so prompts appear as solid blocks in
// scrollback.
let is_user_row = line_meta
.get(idx)
.and_then(TranscriptLineMeta::cell_index)
.map(|cell_index| is_user_cell.get(cell_index).copied().unwrap_or(false))
.unwrap_or(false);
// Line-level styles in ratatui apply to the entire line, but spans can also have their
// own styles. ANSI output is span-based, so we "bake" the line style into every span by
// patching span style with the line style.
let mut merged_spans: Vec<ratatui::text::Span<'static>> = line
.spans
.iter()
.map(|span| ratatui::text::Span {
style: span.style.patch(line.style),
content: span.content.clone(),
})
.collect();
if is_user_row && width > 0 {
// For user rows, pad out to the full width so the background color extends across
// the line in terminal scrollback (mirrors the on-screen viewport behavior).
let text: String = merged_spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let text_width = UnicodeWidthStr::width(text.as_str());
let total_width = usize::from(width);
if text_width < total_width {
let pad_len = total_width.saturating_sub(text_width);
if pad_len > 0 {
let pad_style = crate::style::user_message_style();
merged_spans.push(ratatui::text::Span {
style: pad_style,
content: " ".repeat(pad_len).into(),
});
}
}
}
let mut buf: Vec<u8> = Vec::new();
let _ = crate::insert_history::write_spans(&mut buf, merged_spans.iter());
String::from_utf8(buf).unwrap_or_default()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history_cell::TranscriptLinesWithJoiners;
use pretty_assertions::assert_eq;
use std::sync::Arc;
#[derive(Debug)]
struct FakeCell {
lines: Vec<Line<'static>>,
joiner_before: Vec<Option<String>>,
is_stream_continuation: bool,
}
impl HistoryCell for FakeCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
fn transcript_lines_with_joiners(&self, _width: u16) -> TranscriptLinesWithJoiners {
TranscriptLinesWithJoiners {
lines: self.lines.clone(),
joiner_before: self.joiner_before.clone(),
}
}
fn is_stream_continuation(&self) -> bool {
self.is_stream_continuation
}
}
fn concat_line(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
}
#[test]
fn build_wrapped_transcript_lines_threads_joiners_and_spacers() {
let cells: Vec<Arc<dyn HistoryCell>> = vec![
Arc::new(FakeCell {
lines: vec![Line::from("• hello world")],
joiner_before: vec![None],
is_stream_continuation: false,
}),
Arc::new(FakeCell {
lines: vec![Line::from("• foo bar")],
joiner_before: vec![None],
is_stream_continuation: false,
}),
];
// Force wrapping so we get soft-wrap joiners for the second segment of each cell's line.
let transcript = build_wrapped_transcript_lines(&cells, 8);
assert_eq!(transcript.lines.len(), transcript.meta.len());
assert_eq!(transcript.lines.len(), transcript.joiner_before.len());
let rendered: Vec<String> = transcript.lines.iter().map(concat_line).collect();
assert_eq!(rendered, vec!["• hello", "world", "", "• foo", "bar"]);
assert_eq!(
transcript.meta,
vec![
TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 0
},
TranscriptLineMeta::CellLine {
cell_index: 0,
line_in_cell: 1
},
TranscriptLineMeta::Spacer,
TranscriptLineMeta::CellLine {
cell_index: 1,
line_in_cell: 0
},
TranscriptLineMeta::CellLine {
cell_index: 1,
line_in_cell: 1
},
]
);
assert_eq!(
transcript.joiner_before,
vec![
None,
Some(" ".to_string()),
None,
None,
Some(" ".to_string()),
]
);
}
}

View File

@@ -1,96 +1,63 @@
//! Transcript selection helpers.
//! Transcript selection primitives.
//!
//! This module owns the inline transcript's selection model and helper
//! utilities:
//! The transcript (history) viewport is rendered as a flattened list of visual
//! lines after wrapping. Selection in the transcript needs to be stable across
//! scrolling and terminal resizes, so endpoints are expressed in
//! *content-relative* coordinates:
//!
//! - A **content-relative** selection representation ([`TranscriptSelection`])
//! expressed in terms of flattened, wrapped transcript line indices and
//! columns.
//! - A small mouse-driven **selection state machine** (`on_mouse_*` helpers)
//! that implements "start selection on drag" semantics.
//! - Copy extraction ([`selection_text`]) that matches on-screen glyph layout by
//! rendering selected lines into an offscreen [`ratatui::Buffer`].
//! - `line_index`: index into the flattened, wrapped transcript lines (visual
//! lines).
//! - `column`: a zero-based offset within that visual line, measured from the
//! first content column to the right of the gutter.
//!
//! ## Coordinate model
//! These coordinates are intentionally independent of the current viewport: the
//! user can scroll after selecting, and the selection should continue to refer
//! to the same conversation content.
//!
//! Selection endpoints are expressed in *wrapped* coordinates so they remain
//! stable across scrolling and reflowing when the terminal is resized:
//!
//! - `line_index` is an index into flattened wrapped transcript lines
//! (i.e. "visual lines").
//! - `column` is a 0-based column offset within that visual line, measured from
//! the first content column to the right of the transcript gutter.
//!
//! The transcript gutter is reserved for UI affordances (bullets, prefixes,
//! etc.). The gutter itself is not copyable; both selection highlighting and
//! copy extraction treat selection columns as starting at `base_x =
//! TRANSCRIPT_GUTTER_COLS`.
//! Clipboard reconstruction is implemented in `transcript_copy` (including
//! off-screen lines), while keybinding detection and the on-screen copy
//! affordance live in `transcript_copy_ui`.
//!
//! ## Mouse selection semantics
//!
//! The transcript supports click-and-drag selection for copying text. To avoid
//! distracting "1-cell selections" on a simple click, the selection highlight
//! only becomes active once the user drags:
//!
//! - `on_mouse_down`: stores an **anchor** point and clears any existing head.
//! - `on_mouse_drag`: sets the **head** point, creating an active selection
//! (`anchor` + `head`).
//! - `on_mouse_up`: clears the selection if it never became active (no head) or
//! if the drag ended at the anchor.
//!
//! The helper APIs return whether the selection state changed so callers can
//! schedule a redraw. `on_mouse_drag` also returns whether the caller should
//! lock the transcript scroll position when dragging while following the bottom
//! during streaming output.
//! The transcript supports click-and-drag selection. To avoid leaving a
//! distracting 1-cell highlight on a simple click, the selection only becomes
//! active once a drag updates the head point.
use crate::tui::scrolling::TranscriptScroll;
use itertools::Itertools as _;
use ratatui::prelude::*;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
/// Number of columns reserved for the transcript gutter before the copyable
/// transcript text begins.
/// Number of columns reserved for the transcript gutter (bullet/prefix space).
///
/// Transcript rendering prefixes each line with a short gutter (e.g. `• ` or
/// continuation padding). Selection coordinates intentionally exclude this
/// gutter so selection/copy operates on content columns instead of terminal
/// absolute columns.
pub(crate) const TRANSCRIPT_GUTTER_COLS: u16 = 2;
/// Content-relative selection within the inline transcript viewport.
///
/// Selection endpoints are expressed in terms of flattened, wrapped transcript
/// line indices and columns, so the highlight tracks logical conversation
/// content even when the viewport scrolls or the terminal is resized.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct TranscriptSelection {
/// The selection anchor (fixed start) in transcript coordinates.
/// The initial selection point (where the selection drag started).
///
/// This remains fixed while dragging; the highlighted region is the span
/// between `anchor` and `head`.
pub(crate) anchor: Option<TranscriptSelectionPoint>,
/// The selection head (moving end) in transcript coordinates.
/// The current selection point (where the selection drag currently ends).
///
/// This is `None` until the user drags, which prevents a simple click from
/// creating a persistent selection highlight.
pub(crate) head: Option<TranscriptSelectionPoint>,
}
impl TranscriptSelection {
/// Create an active selection with both endpoints set.
#[cfg(test)]
pub(crate) fn new(
anchor: impl Into<TranscriptSelectionPoint>,
head: impl Into<TranscriptSelectionPoint>,
) -> Self {
Self {
anchor: Some(anchor.into()),
head: Some(head.into()),
}
}
}
/// A single endpoint of a transcript selection.
///
/// `line_index` is an index into the flattened wrapped transcript lines, and
/// `column` is a zero-based column offset within that visual line, counted from
/// the first content column to the right of the transcript gutter.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct TranscriptSelectionPoint {
/// Index into the flattened wrapped transcript lines.
/// Index into the flattened, wrapped transcript lines.
pub(crate) line_index: usize,
/// Zero-based column offset within the wrapped line, relative to the first
/// content column to the right of the transcript gutter.
/// Zero-based content column (excluding the gutter).
///
/// This is not a terminal absolute column: callers add the gutter offset
/// when mapping it to a rendered buffer row.
pub(crate) column: u16,
}
@@ -102,12 +69,23 @@ impl TranscriptSelectionPoint {
}
impl From<(usize, u16)> for TranscriptSelectionPoint {
/// Convert from `(line_index, column)`.
fn from((line_index, column): (usize, u16)) -> Self {
Self::new(line_index, column)
}
}
/// Return `(start, end)` with `start <= end` in transcript order.
pub(crate) fn ordered_endpoints(
anchor: TranscriptSelectionPoint,
head: TranscriptSelectionPoint,
) -> (TranscriptSelectionPoint, TranscriptSelectionPoint) {
if anchor <= head {
(anchor, head)
} else {
(head, anchor)
}
}
/// Begin a potential transcript selection (left button down).
///
/// This records an anchor point and clears any existing head. The selection is
@@ -235,394 +213,11 @@ fn end(selection: &mut TranscriptSelection) {
}
}
/// Extract the full transcript selection as plain text.
///
/// This intentionally does *not* use viewport state. Instead it:
///
/// - Applies the same word-wrapping used for on-screen rendering, producing
/// flattened "visual" lines.
/// - Renders each selected visual line into a 1-row offscreen `Buffer` and
/// extracts the selected character cells from that buffer.
///
/// Using the rendered buffer (instead of slicing the source strings) keeps copy
/// semantics aligned with what the user sees on screen, including:
///
/// - Prefixes / indentation introduced during rendering (e.g. list markers).
/// - The transcript gutter: selection columns are defined relative to the
/// first content column to the right of the gutter (`base_x =
/// TRANSCRIPT_GUTTER_COLS`).
/// - Multi-cell glyph rendering decisions made by the backend.
///
/// Notes:
///
/// - Trailing padding to the right margin is not included; we clamp each line
/// to the last non-space glyph to avoid copying a full-width block of spaces.
/// - `TranscriptSelectionPoint::column` can be arbitrarily large (e.g.
/// `u16::MAX` when dragging to the right edge); we clamp to the rendered line
/// width so "copy to end of line" behaves naturally.
pub(crate) fn selection_text(
lines: &[Line<'static>],
selection: TranscriptSelection,
width: u16,
) -> Option<String> {
let (anchor, head) = selection.anchor.zip(selection.head)?;
if anchor == head {
return None;
}
let (start, end) = ordered_endpoints(anchor, head);
let wrapped = wrap_transcript_lines(lines, width)?;
let ctx = RenderContext::new(width)?;
let total_lines = wrapped.len();
if start.line_index >= total_lines {
return None;
}
// If the selection ends beyond the last wrapped line, clamp it so selection
// behaves like "copy through the end" rather than returning no text.
let (end_line_index, end_is_clamped) = clamp_end_line(end.line_index, total_lines)?;
let mut buf = Buffer::empty(ctx.area);
let mut lines_out: Vec<String> = Vec::new();
for (line_index, line) in wrapped
.iter()
.enumerate()
.take(end_line_index + 1)
.skip(start.line_index)
{
buf.reset();
line.render_ref(ctx.area, &mut buf);
let Some((row_sel_start, row_sel_end)) =
ctx.selection_bounds_for_line(line_index, start, end, end_is_clamped)
else {
// Preserve row count/newlines within the selection even if this
// particular visual line produces no selected cells.
lines_out.push(String::new());
continue;
};
let Some(content_end_x) = ctx.content_end_x(&buf) else {
// Preserve explicit blank lines (e.g., spacer rows) in the selection.
lines_out.push(String::new());
continue;
};
let from_x = row_sel_start.max(ctx.base_x);
let to_x = row_sel_end.min(content_end_x);
if from_x > to_x {
// Preserve row count/newlines even when selection falls beyond the
// rendered content for this visual line.
lines_out.push(String::new());
continue;
}
lines_out.push(ctx.extract_text(&buf, from_x, to_x));
}
Some(lines_out.join("\n"))
}
/// Return `(start, end)` with `start <= end` in transcript order.
pub(crate) fn ordered_endpoints(
anchor: TranscriptSelectionPoint,
head: TranscriptSelectionPoint,
) -> (TranscriptSelectionPoint, TranscriptSelectionPoint) {
if anchor <= head {
(anchor, head)
} else {
(head, anchor)
}
}
/// Wrap transcript lines using the same algorithm as on-screen rendering.
///
/// Returns `None` for invalid widths or when wrapping produces no visual lines.
fn wrap_transcript_lines<'a>(lines: &'a [Line<'static>], width: u16) -> Option<Vec<Line<'a>>> {
if width == 0 || lines.is_empty() {
return None;
}
let wrapped = crate::wrapping::word_wrap_lines_borrowed(lines, width.max(1) as usize);
(!wrapped.is_empty()).then_some(wrapped)
}
/// Context for rendering a single wrapped transcript line into a 1-row buffer and
/// extracting selected cells.
#[derive(Debug, Clone, Copy)]
struct RenderContext {
/// One-row region used for offscreen rendering.
area: Rect,
/// X coordinate where copyable transcript content begins (gutter skipped).
base_x: u16,
/// Maximum X coordinate inside the render area (inclusive).
max_x: u16,
/// Maximum content-relative column (0-based) within the render area.
max_content_col: u16,
}
impl RenderContext {
/// Create a 1-row render context for a given terminal width.
///
/// Returns `None` when the width is too small to hold any copyable content
/// (e.g. the gutter consumes the entire row).
fn new(width: u16) -> Option<Self> {
if width == 0 {
return None;
}
let area = Rect::new(0, 0, width, 1);
let base_x = area.x.saturating_add(TRANSCRIPT_GUTTER_COLS);
let max_x = area.right().saturating_sub(1);
if base_x > max_x {
return None;
}
Some(Self {
area,
base_x,
max_x,
max_content_col: max_x.saturating_sub(base_x),
})
}
/// Compute the inclusive selection X range for this visual line.
///
/// `start`/`end` columns are content-relative (0 starts at the first column
/// to the right of the transcript gutter). For the terminal line containing
/// the selection endpoint, this clamps the selection to that endpoint; for
/// intermediate lines it selects the whole line.
///
/// If the selection end was clamped to the last available line (meaning the
/// logical selection extended beyond the rendered transcript), the final
/// line is treated as selecting through the end of that line.
fn selection_bounds_for_line(
&self,
line_index: usize,
start: TranscriptSelectionPoint,
end: TranscriptSelectionPoint,
end_is_clamped: bool,
) -> Option<(u16, u16)> {
let line_start_col = if line_index == start.line_index {
start.column
} else {
0
};
let line_end_col = if !end_is_clamped && line_index == end.line_index {
end.column
} else {
self.max_content_col
};
let row_sel_start = self.base_x.saturating_add(line_start_col);
let row_sel_end = self.base_x.saturating_add(line_end_col).min(self.max_x);
(row_sel_start <= row_sel_end).then_some((row_sel_start, row_sel_end))
}
/// Find the last non-space glyph in the rendered content area.
///
/// This is used to avoid copying right-margin padding when the rendered row
/// is shorter than the terminal width.
fn content_end_x(&self, buf: &Buffer) -> Option<u16> {
(self.base_x..=self.max_x)
.rev()
.find(|&x| buf[(x, 0)].symbol() != " ")
}
/// Extract rendered cell contents from an inclusive `[from_x, to_x]` range.
///
/// Note: terminals represent wide glyphs (e.g. CJK characters) using multiple
/// cells, but only the first cell contains the glyph's symbol. The remaining
/// cells are "continuation" cells that should not be copied as separate
/// characters. Ratatui marks those continuation cells as a single space in
/// the buffer, so we must explicitly skip `width - 1` following cells after
/// reading each rendered symbol to avoid producing output like `"コ X"`.
fn extract_text(&self, buf: &Buffer, from_x: u16, to_x: u16) -> String {
(from_x..=to_x)
.batching(|xs| {
let x = xs.next()?;
let symbol = buf[(x, 0)].symbol();
for _ in 0..symbol.width().saturating_sub(1) {
xs.next();
}
(!symbol.is_empty()).then_some(symbol)
})
.join("")
}
}
/// Clamp `end_line_index` to the last available line and report if it was clamped.
///
/// Returns `None` when there are no wrapped lines.
fn clamp_end_line(end_line_index: usize, total_lines: usize) -> Option<(usize, bool)> {
if total_lines == 0 {
return None;
}
let clamped = end_line_index.min(total_lines.saturating_sub(1));
Some((clamped, clamped != end_line_index))
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn selection_text_returns_none_when_missing_endpoints() {
let lines = vec![Line::from(vec!["".into(), "Hello".into()])];
let selection = TranscriptSelection::default();
assert_eq!(selection_text(&lines, selection, 40), None);
}
#[test]
fn selection_text_returns_none_when_endpoints_equal() {
let lines = vec![Line::from(vec!["".into(), "Hello".into()])];
let selection = TranscriptSelection::new((0, 2), (0, 2));
assert_eq!(selection_text(&lines, selection, 40), None);
}
#[test]
fn selection_text_returns_none_for_empty_lines() {
let selection = TranscriptSelection::new((0, 0), (0, 1));
assert_eq!(selection_text(&[], selection, 40), None);
}
#[test]
fn selection_text_returns_none_for_zero_width() {
let lines = vec![Line::from(vec!["".into(), "Hello".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 1));
assert_eq!(selection_text(&lines, selection, 0), None);
}
#[test]
fn selection_text_returns_none_when_width_smaller_than_gutter() {
let lines = vec![Line::from(vec!["".into(), "Hello".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 1));
assert_eq!(selection_text(&lines, selection, 2), None);
}
#[test]
fn selection_text_skips_gutter_prefix() {
let lines = vec![Line::from(vec!["".into(), "Hello".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 4));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "Hello");
}
#[test]
fn selection_text_selects_substring_single_line() {
let lines = vec![Line::from(vec!["".into(), "Hello world".into()])];
let selection = TranscriptSelection::new((0, 6), (0, 10));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "world");
}
#[test]
fn selection_text_preserves_interior_spaces() {
let lines = vec![Line::from(vec!["".into(), "a b".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 3));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "a b");
}
#[test]
fn selection_text_skips_hidden_wide_glyph_cells() {
let lines = vec![Line::from(vec!["".into(), "コX".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 2));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "コX");
}
#[test]
fn selection_text_orders_reversed_endpoints() {
let lines = vec![Line::from(vec!["".into(), "Hello world".into()])];
let selection = TranscriptSelection::new((0, 10), (0, 6));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "world");
}
#[test]
fn selection_text_selects_multiple_lines_with_partial_endpoints() {
let lines = vec![
Line::from(vec!["".into(), "abcde".into()]),
Line::from(vec!["".into(), "fghij".into()]),
Line::from(vec!["".into(), "klmno".into()]),
];
let selection = TranscriptSelection::new((0, 2), (2, 2));
assert_eq!(
selection_text(&lines, selection, 40).unwrap(),
"cde\nfghij\nklm"
);
}
#[test]
fn selection_text_selects_to_end_of_line_for_large_column() {
let lines = vec![Line::from(vec!["".into(), "one".into()])];
let selection = TranscriptSelection::new((0, 0), (0, u16::MAX));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one");
}
#[test]
fn selection_text_includes_indentation_spaces() {
let lines = vec![Line::from(vec!["".into(), " ind".into()])];
let selection = TranscriptSelection::new((0, 0), (0, 4));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), " ind");
}
#[test]
fn selection_text_preserves_empty_lines() {
let lines = vec![
Line::from(vec!["".into(), "one".into()]),
Line::from(""),
Line::from(vec!["".into(), "two".into()]),
];
let selection = TranscriptSelection::new((0, 0), (2, 2));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one\n\ntwo");
}
#[test]
fn selection_text_clamps_end_line_index() {
let lines = vec![
Line::from(vec!["".into(), "one".into()]),
Line::from(vec!["".into(), "two".into()]),
];
let selection = TranscriptSelection::new((0, 0), (100, u16::MAX));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one\ntwo");
}
#[test]
fn selection_text_clamps_end_line_index_ignoring_end_column() {
let lines = vec![
Line::from(vec!["".into(), "one".into()]),
Line::from(vec!["".into(), "two".into()]),
];
let selection = TranscriptSelection::new((0, 0), (100, 0));
assert_eq!(selection_text(&lines, selection, 40).unwrap(), "one\ntwo");
}
#[test]
fn selection_text_returns_none_when_start_line_out_of_range() {
let lines = vec![Line::from(vec!["".into(), "one".into()])];
let selection = TranscriptSelection::new((100, 0), (101, 0));
assert_eq!(selection_text(&lines, selection, 40), None);
}
#[test]
fn selection_only_highlights_on_drag() {
let anchor = TranscriptSelectionPoint::new(0, 1);

View File

@@ -155,6 +155,13 @@ pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) ->
where
O: Into<RtOptions<'a>>,
{
let (lines, _joiners) = word_wrap_line_with_joiners(line, width_or_options);
lines
}
fn flatten_line_and_bounds<'a>(
line: &'a Line<'a>,
) -> (String, Vec<(Range<usize>, ratatui::style::Style)>) {
// Flatten the line and record span byte ranges.
let mut flat = String::new();
let mut span_bounds = Vec::new();
@@ -166,6 +173,43 @@ where
acc += text.len();
span_bounds.push((start..acc, s.style));
}
(flat, span_bounds)
}
fn build_wrapped_line_from_range<'a>(
indent: Line<'a>,
original: &'a Line<'a>,
span_bounds: &[(Range<usize>, ratatui::style::Style)],
range: &Range<usize>,
) -> Line<'a> {
let mut out = indent.style(original.style);
let sliced = slice_line_spans(original, span_bounds, range);
let mut spans = out.spans;
spans.append(
&mut sliced
.spans
.into_iter()
.map(|s| s.patch_style(original.style))
.collect(),
);
out.spans = spans;
out
}
/// Wrap a single line and also return, for each output line, the string that should be inserted
/// when joining it to the previous output line as a *soft wrap*.
///
/// - The first output line always has `None`.
/// - Continuation lines have `Some(joiner)` where `joiner` is the exact substring (often spaces,
/// possibly empty) that was skipped at the wrap boundary.
pub(crate) fn word_wrap_line_with_joiners<'a, O>(
line: &'a Line<'a>,
width_or_options: O,
) -> (Vec<Line<'a>>, Vec<Option<String>>)
where
O: Into<RtOptions<'a>>,
{
let (flat, span_bounds) = flatten_line_and_bounds(line);
let rt_opts: RtOptions<'a> = width_or_options.into();
let opts = Options::new(rt_opts.width)
@@ -176,7 +220,9 @@ where
.word_splitter(rt_opts.word_splitter);
let mut out: Vec<Line<'a>> = Vec::new();
let mut joiners: Vec<Option<String>> = Vec::new();
// The first output line uses the initial indent and a reduced available width.
// Compute first line range with reduced width due to initial indent.
let initial_width_available = opts
.width
@@ -184,54 +230,100 @@ where
.max(1);
let initial_wrapped = wrap_ranges_trim(&flat, opts.clone().width(initial_width_available));
let Some(first_line_range) = initial_wrapped.first() else {
return vec![rt_opts.initial_indent.clone()];
out.push(rt_opts.initial_indent.clone());
joiners.push(None);
return (out, joiners);
};
// Build first wrapped line with initial indent.
let mut first_line = rt_opts.initial_indent.clone().style(line.style);
{
let sliced = slice_line_spans(line, &span_bounds, first_line_range);
let mut spans = first_line.spans;
spans.append(
&mut sliced
.spans
.into_iter()
.map(|s| s.patch_style(line.style))
.collect(),
);
first_line.spans = spans;
out.push(first_line);
}
let first_line = build_wrapped_line_from_range(
rt_opts.initial_indent.clone(),
line,
&span_bounds,
first_line_range,
);
out.push(first_line);
joiners.push(None);
// Wrap the remainder using subsequent indent width and map back to original indices.
let base = first_line_range.end;
// Wrap the remainder using subsequent indent width. We also compute the joiner strings that
// were skipped at each wrap boundary so callers can treat these as soft wraps during copy.
let mut base = first_line_range.end;
let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
let base = base + skip_leading_spaces;
let joiner_first = flat[base..base.saturating_add(skip_leading_spaces)].to_string();
base = base.saturating_add(skip_leading_spaces);
let subsequent_width_available = opts
.width
.saturating_sub(rt_opts.subsequent_indent.width())
.max(1);
let remaining_wrapped = wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available));
for r in &remaining_wrapped {
let remaining = &flat[base..];
let remaining_wrapped = wrap_ranges_trim(remaining, opts.width(subsequent_width_available));
let mut prev_end = 0usize;
for (i, r) in remaining_wrapped.iter().enumerate() {
if r.is_empty() {
continue;
}
let mut subsequent_line = rt_opts.subsequent_indent.clone().style(line.style);
// Each continuation line has `Some(joiner)`. The joiner may be empty (e.g. splitting a
// long word), but the distinction from `None` is important: `None` represents a hard break.
let joiner = if i == 0 {
joiner_first.clone()
} else {
remaining[prev_end..r.start].to_string()
};
prev_end = r.end;
let offset_range = (r.start + base)..(r.end + base);
let sliced = slice_line_spans(line, &span_bounds, &offset_range);
let mut spans = subsequent_line.spans;
spans.append(
&mut sliced
.spans
.into_iter()
.map(|s| s.patch_style(line.style))
.collect(),
let subsequent_line = build_wrapped_line_from_range(
rt_opts.subsequent_indent.clone(),
line,
&span_bounds,
&offset_range,
);
subsequent_line.spans = spans;
out.push(subsequent_line);
joiners.push(Some(joiner));
}
out
(out, joiners)
}
/// Like `word_wrap_lines`, but also returns a parallel vector of soft-wrap joiners.
///
/// The joiner is `None` when the line break is a hard break (between input lines), and `Some`
/// when the line break is a soft wrap continuation produced by the wrapping algorithm.
#[allow(private_bounds)] // IntoLineInput isn't public, but it doesn't really need to be.
pub(crate) fn word_wrap_lines_with_joiners<'a, I, O, L>(
lines: I,
width_or_options: O,
) -> (Vec<Line<'static>>, Vec<Option<String>>)
where
I: IntoIterator<Item = L>,
L: IntoLineInput<'a>,
O: Into<RtOptions<'a>>,
{
let base_opts: RtOptions<'a> = width_or_options.into();
let mut out: Vec<Line<'static>> = Vec::new();
let mut joiners: Vec<Option<String>> = Vec::new();
for (idx, line) in lines.into_iter().enumerate() {
let line_input = line.into_line_input();
let opts = if idx == 0 {
base_opts.clone()
} else {
let mut o = base_opts.clone();
let sub = o.subsequent_indent.clone();
o = o.initial_indent(sub);
o
};
let (wrapped, wrapped_joiners) = word_wrap_line_with_joiners(line_input.as_ref(), opts);
for (l, j) in wrapped.into_iter().zip(wrapped_joiners) {
out.push(crate::render::line_utils::line_to_static(&l));
joiners.push(j);
}
}
(out, joiners)
}
/// Utilities to allow wrapping either borrowed or owned lines.
@@ -552,6 +644,66 @@ mod tests {
assert_eq!(concat_line(&out[1]), "cd");
}
#[test]
fn wrap_line_with_joiners_matches_word_wrap_line_output() {
let opts = RtOptions::new(8)
.initial_indent(Line::from("- "))
.subsequent_indent(Line::from(" "));
let line = Line::from(vec!["hello ".red(), "world".into()]);
let out = word_wrap_line(&line, opts.clone());
let (with_joiners, joiners) = word_wrap_line_with_joiners(&line, opts);
assert_eq!(
with_joiners.iter().map(concat_line).collect_vec(),
out.iter().map(concat_line).collect_vec()
);
assert_eq!(joiners.len(), with_joiners.len());
assert_eq!(
joiners.first().cloned().unwrap_or(Some("x".to_string())),
None
);
}
#[test]
fn wrap_line_with_joiners_includes_skipped_spaces() {
let line = Line::from("hello world");
let (wrapped, joiners) = word_wrap_line_with_joiners(&line, 8);
assert_eq!(
wrapped.iter().map(concat_line).collect_vec(),
vec!["hello", "world"]
);
assert_eq!(joiners, vec![None, Some(" ".to_string())]);
}
#[test]
fn wrap_line_with_joiners_uses_empty_joiner_for_mid_word_split() {
let line = Line::from("abcd");
let (wrapped, joiners) = word_wrap_line_with_joiners(&line, 2);
assert_eq!(
wrapped.iter().map(concat_line).collect_vec(),
vec!["ab", "cd"]
);
assert_eq!(joiners, vec![None, Some("".to_string())]);
}
#[test]
fn wrap_lines_with_joiners_marks_hard_breaks_between_input_lines() {
let (wrapped, joiners) =
word_wrap_lines_with_joiners([Line::from("hello world"), Line::from("foo bar")], 5);
assert_eq!(
wrapped.iter().map(concat_line).collect_vec(),
vec!["hello", "world", "foo", "bar"]
);
assert_eq!(
joiners,
vec![None, Some(" ".to_string()), None, Some(" ".to_string())]
);
}
#[test]
fn wrap_lines_applies_initial_indent_only_once() {
let opts = RtOptions::new(8)