mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Compare commits
6 Commits
1271d450b1
...
joshka/tui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c54634f7 | ||
|
|
060c44ff78 | ||
|
|
bc9ab79d09 | ||
|
|
4ce57ef890 | ||
|
|
c84b2eb22c | ||
|
|
c8901b3784 |
248
codex-rs/tui2/docs/transcript_find.md
Normal file
248
codex-rs/tui2/docs/transcript_find.md
Normal 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 it’s 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 line’s 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 isn’t 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.
|
||||
@@ -183,13 +183,18 @@ Mouse interaction is a first‑class part of the new design:
|
||||
that we use for bullets/prefixes.
|
||||
|
||||
- **Copy.**
|
||||
- When the user triggers copy, the TUI reconstructs the same wrapped transcript lines used for
|
||||
on-screen rendering.
|
||||
- It then walks the content-relative selection range (even if the selection extends outside the
|
||||
current viewport) and re-renders each selected visual line into a 1-row offscreen buffer to
|
||||
reconstruct the exact text region the user highlighted (including internal spaces and empty
|
||||
lines, while skipping wide-glyph continuation cells and right-margin padding).
|
||||
- That text is sent to the system clipboard and a status footer indicates success or failure.
|
||||
- When the user triggers copy, the TUI reconstructs the wrapped transcript lines using the same
|
||||
flattening/wrapping rules as the visible view.
|
||||
- It then reconstructs a high‑fidelity clipboard string from the selected logical lines:
|
||||
- Preserves meaningful indentation (especially for code blocks).
|
||||
- Treats soft-wrapped prose as a single logical line by joining wrap continuations instead of
|
||||
inserting hard newlines.
|
||||
- Emits Markdown source markers (e.g. backticks and fences) for copy/paste, even if the UI
|
||||
chooses to render those constructs without showing the literal markers.
|
||||
- Copy operates on the full selection range, even if the selection extends outside the current
|
||||
viewport.
|
||||
- The resulting text is sent to the system clipboard and a status footer indicates success or
|
||||
failure.
|
||||
|
||||
Because scrolling, selection, and copy all operate on the same flattened transcript representation,
|
||||
they remain consistent even as the viewport resizes or the chat composer grows/shrinks. Owning our
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
@@ -382,34 +383,20 @@ fn blockquote_heading_inherits_heading_style() {
|
||||
fn blockquote_with_code_block() {
|
||||
let md = "> ```\n> code\n> ```\n";
|
||||
let text = render_markdown_text(md);
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines, vec!["> code".to_string()]);
|
||||
assert_eq!(text.lines, [Line::from_iter(["> ", "", "code"]).cyan()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockquote_with_multiline_code_block() {
|
||||
let md = "> ```\n> first\n> second\n> ```\n";
|
||||
let text = render_markdown_text(md);
|
||||
let lines: Vec<String> = text
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(lines, vec!["> first", "> second"]);
|
||||
assert_eq!(
|
||||
text.lines,
|
||||
[
|
||||
Line::from_iter(["> ", "", "first"]).cyan(),
|
||||
Line::from_iter(["> ", "", "second"]).cyan(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -453,6 +440,12 @@ fn nested_blockquote_with_inline_and_fenced_code() {
|
||||
"> > echo \"hello from a quote\"".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
// Fenced code inside nested blockquotes should keep code styling so copy logic can treat it as
|
||||
// preformatted.
|
||||
for idx in [4usize, 5usize] {
|
||||
assert_eq!(text.lines[idx].style.fg, Some(Color::Cyan));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -654,7 +647,7 @@ fn link() {
|
||||
#[test]
|
||||
fn code_block_unhighlighted() {
|
||||
let text = render_markdown_text("```rust\nfn main() {}\n```\n");
|
||||
let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"])]);
|
||||
let expected = Text::from_iter([Line::from_iter(["", "fn main() {}"]).cyan()]);
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
@@ -663,8 +656,8 @@ fn code_block_multiple_lines_root() {
|
||||
let md = "```\nfirst\nsecond\n```\n";
|
||||
let text = render_markdown_text(md);
|
||||
let expected = Text::from_iter([
|
||||
Line::from_iter(["", "first"]),
|
||||
Line::from_iter(["", "second"]),
|
||||
Line::from_iter(["", "first"]).cyan(),
|
||||
Line::from_iter(["", "second"]).cyan(),
|
||||
]);
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
@@ -674,9 +667,9 @@ fn code_block_indented() {
|
||||
let md = " function greet() {\n console.log(\"Hi\");\n }\n";
|
||||
let text = render_markdown_text(md);
|
||||
let expected = Text::from_iter([
|
||||
Line::from_iter([" ", "function greet() {"]),
|
||||
Line::from_iter([" ", " console.log(\"Hi\");"]),
|
||||
Line::from_iter([" ", "}"]),
|
||||
Line::from_iter([" ", "function greet() {"]).cyan(),
|
||||
Line::from_iter([" ", " console.log(\"Hi\");"]).cyan(),
|
||||
Line::from_iter([" ", "}"]).cyan(),
|
||||
]);
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
332
codex-rs/tui2/src/transcript_copy_ui.rs
Normal file
332
codex-rs/tui2/src/transcript_copy_ui.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
889
codex-rs/tui2/src/transcript_find.rs
Normal file
889
codex-rs/tui2/src/transcript_find.rs
Normal 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'))));
|
||||
}
|
||||
}
|
||||
1221
codex-rs/tui2/src/transcript_multi_click.rs
Normal file
1221
codex-rs/tui2/src/transcript_multi_click.rs
Normal file
File diff suppressed because it is too large
Load Diff
399
codex-rs/tui2/src/transcript_render.rs
Normal file
399
codex-rs/tui2/src/transcript_render.rs
Normal 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()),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user