feat(tui): expand owned-screen text selection

This commit is contained in:
Felipe Coury
2026-07-05 15:56:06 -03:00
parent 402e5da0b0
commit d52a1360e5
48 changed files with 5738 additions and 626 deletions
+24
View File
@@ -3,7 +3,11 @@
use super::ThreadBufferedEvent;
use super::ThreadEventStore;
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
use crate::history_cell::SelectionContribution;
use crate::history_cell::plain_lines;
use crate::history_cell::selection_contribution_from_semantic_text;
use crate::history_cell::selection_text_from_lines;
use crate::text_formatting::truncate_text;
use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::ServerNotification;
@@ -60,6 +64,26 @@ impl HistoryCell for AgentStatusHistoryCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
let lines = self.display_lines_for_mode(width, mode);
let semantic_lines = lines
.iter()
.map(|line| {
let text = selection_text_from_lines(std::slice::from_ref(line));
text.strip_prefix(" • ")
.or_else(|| text.strip_prefix(" "))
.unwrap_or(&text)
.to_string()
})
.collect::<Vec<_>>();
selection_contribution_from_semantic_text(
semantic_lines.join("\n"),
lines,
width,
/*first_row_prefix_columns*/ 0,
)
}
}
#[derive(Debug)]
+9 -2
View File
@@ -138,8 +138,15 @@ impl App {
self.chat_widget.submit_op(AppCommand::reload_user_config());
self.fetch_plugins_list(app_server_client, cwd);
if should_report_completion {
self.chat_widget.add_plain_history_lines(
crate::external_agent_config_migration_flow::external_agent_config_migration_finished_lines(notification),
let lines = crate::external_agent_config_migration_flow::external_agent_config_migration_finished_lines(notification);
let (selection_text, prefix_columns) =
crate::external_agent_config_migration_flow::external_agent_config_migration_selection(
&lines,
);
self.chat_widget.add_semantic_history_lines(
lines,
selection_text,
prefix_columns,
);
}
return;
+15 -7
View File
@@ -162,7 +162,15 @@ impl App {
.await
{
Ok(ExternalAgentConfigMigrationFlowOutcome::Started(lines)) => {
self.chat_widget.add_plain_history_lines(lines);
let (selection_text, prefix_columns) =
crate::external_agent_config_migration_flow::external_agent_config_migration_selection(
&lines,
);
self.chat_widget.add_semantic_history_lines(
lines,
selection_text,
prefix_columns,
);
}
Ok(ExternalAgentConfigMigrationFlowOutcome::NoItems) => {
self.chat_widget.add_info_message(
@@ -211,7 +219,7 @@ impl App {
self.chat_widget.rollout_path().as_deref(),
);
self.chat_widget
.add_plain_history_lines(vec!["/fork".magenta().into()]);
.add_literal_history_lines(vec!["/fork".magenta().into()]);
if let Some(thread_id) = self.chat_widget.thread_id() {
self.refresh_in_memory_config_from_disk_best_effort("forking the thread")
.await;
@@ -237,7 +245,7 @@ impl App {
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
self.chat_widget.add_literal_history_lines(lines);
}
}
Err(err) => {
@@ -1547,14 +1555,14 @@ impl App {
if self.apply_permission_profile_selection(selection).await {
self.chat_widget.submit_initial_user_message_if_pending();
}
self.chat_widget.add_plain_history_lines(vec![
self.chat_widget.add_semantic_history_lines(vec![
Line::from(vec!["• ".dim(), "Sandbox ready".into()]),
Line::from(vec![
" ".into(),
"Codex can now safely edit files and execute commands in your computer"
.dark_gray(),
]),
]);
], "Sandbox ready\nCodex can now safely edit files and execute commands in your computer".to_string(), /*first_row_prefix_columns*/ vec![2, 2]);
} else {
self.app_event_tx.send(AppEvent::CodexOp(
AppCommand::override_turn_context(
@@ -1580,14 +1588,14 @@ impl App {
.send(AppEvent::UpdateActivePermissionProfile(
preset.active_permission_profile.clone(),
));
self.chat_widget.add_plain_history_lines(vec![
self.chat_widget.add_semantic_history_lines(vec![
Line::from(vec!["• ".dim(), "Sandbox ready".into()]),
Line::from(vec![
" ".into(),
"Codex can now safely edit files and execute commands in your computer"
.dark_gray(),
]),
]);
], "Sandbox ready\nCodex can now safely edit files and execute commands in your computer".to_string(), /*first_row_prefix_columns*/ vec![2, 2]);
}
}
Err(err) => {
+34 -7
View File
@@ -84,7 +84,7 @@ impl OwnedScreen {
let active_key = chat_widget.active_cell_render_key();
self.viewport
.sync_live_tail(conversation_area.width, active_key, |width| {
chat_widget.active_cell_display_hyperlink_lines(width)
chat_widget.active_cell_display_snapshots(width)
});
self.viewport.render(conversation_area, buffer);
bottom_pane.render(bottom_area, buffer);
@@ -117,6 +117,11 @@ impl OwnedScreen {
fn handle_mouse_scroll(&mut self, event: MouseScrollEvent) -> bool {
if self.viewport.selection_is_active() {
self.viewport.handle_selection_mouse_scroll(
self.last_conversation_area,
event.direction,
Position::new(event.column, event.row),
);
return true;
}
if !self
@@ -130,8 +135,18 @@ impl OwnedScreen {
}
fn begin_selection(&mut self, position: Position) -> bool {
self.viewport
.begin_selection(self.last_conversation_area, position)
let conversation_area = self.last_conversation_area;
let position = if !conversation_area.is_empty()
&& self.last_pane_area.contains(position)
&& position.y >= conversation_area.y
&& position.y < conversation_area.bottom()
&& position.x >= conversation_area.right()
{
Position::new(conversation_area.right().saturating_sub(1), position.y)
} else {
position
};
self.viewport.begin_selection(conversation_area, position)
}
fn update_selection(&mut self, position: Position) -> bool {
@@ -366,14 +381,26 @@ impl App {
tui: &mut tui::Tui,
event: MouseScrollEvent,
) -> bool {
let selection_is_active = [PaneSlot::Parent, PaneSlot::Side].into_iter().any(|slot| {
if self.chat_widget.owned_screen_split_is_dragging() {
return true;
}
let active_selection_slot = [PaneSlot::Parent, PaneSlot::Side].into_iter().find(|slot| {
self.chat_widget
.by_slot(slot)
.by_slot(*slot)
.and_then(|pane| pane.owned_screen.as_ref())
.is_some_and(OwnedScreen::selection_is_active)
});
if self.chat_widget.owned_screen_split_is_dragging() || selection_is_active {
return true;
if let Some(slot) = active_selection_slot {
let handled = self
.chat_widget
.by_slot_mut(slot)
.and_then(|pane| pane.owned_screen.as_mut())
.is_some_and(|screen| screen.handle_mouse_scroll(event));
if handled {
tui.frame_requester()
.schedule_frame_in(crate::tui::TARGET_FRAME_INTERVAL);
}
return handled;
}
for slot in [PaneSlot::Parent, PaneSlot::Side] {
let handled = self.chat_widget.by_slot_mut(slot).is_some_and(|pane| {
+136
View File
@@ -34,6 +34,17 @@ impl HistoryCell for TestCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![self.0.into()]
}
fn selection_contribution(
&self,
width: u16,
mode: crate::history_cell::HistoryRenderMode,
) -> crate::history_cell::SelectionContribution {
crate::history_cell::selection_contribution_from_display_lines(
self.display_lines_for_mode(width, mode),
width,
)
}
}
#[derive(Debug)]
@@ -50,6 +61,17 @@ impl HistoryCell for RenderModeCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![self.raw.into()]
}
fn selection_contribution(
&self,
width: u16,
mode: crate::history_cell::HistoryRenderMode,
) -> crate::history_cell::SelectionContribution {
crate::history_cell::selection_contribution_from_display_lines(
self.display_lines_for_mode(width, mode),
width,
)
}
}
async fn app_with_owned_parent() -> App {
@@ -422,6 +444,63 @@ async fn primary_drag_selects_text_in_a_single_owned_pane() {
}
}
#[tokio::test]
async fn primary_drag_can_start_in_pet_reserved_right_padding() {
let mut app = app_with_owned_parent().await;
app.chat_widget
.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(
crate::pets::ImageProtocol::Kitty,
));
app.chat_widget
.install_test_ambient_pet_for_tests(/*animations_enabled*/ false);
seed_pane(&mut app, PaneSlot::Parent, "", &["selectable"]);
let _terminal = render_app(&mut app, /*width*/ 40, /*height*/ 10);
let (pane_area, conversation_area) = {
let screen = app
.chat_widget
.owned_screen
.as_ref()
.expect("parent owned screen");
(screen.last_pane_area, screen.last_conversation_area)
};
assert!(conversation_area.right() < pane_area.right());
let padding_column = pane_area.right().saturating_sub(/*rhs*/ 1);
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
primary_press(padding_column, conversation_area.y),
));
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
primary_event(
MousePrimaryEventKind::Drag,
conversation_area.x,
conversation_area.y,
),
));
assert!(
app.chat_widget
.owned_screen
.as_ref()
.expect("parent owned screen")
.selection_is_active()
);
app.cancel_owned_screen_selection();
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
primary_press(padding_column, conversation_area.bottom()),
));
assert!(
!app.chat_widget
.owned_screen
.as_ref()
.expect("parent owned screen")
.selection_is_active()
);
}
#[tokio::test]
async fn click_release_clears_selection_without_copying() {
let mut app = app_with_owned_parent().await;
@@ -943,6 +1022,63 @@ async fn mouse_wheel_routes_by_pointer_without_changing_focus() {
assert!(!is_following_bottom(&app, PaneSlot::Side));
}
#[tokio::test]
async fn mouse_wheel_routes_to_the_pane_with_an_active_selection() {
let mut app = app_with_owned_side().await;
let cells = [
"one", "two", "three", "four", "five", "six", "seven", "eight",
];
seed_pane(&mut app, PaneSlot::Parent, "", &cells);
seed_pane(&mut app, PaneSlot::Side, "", &cells);
let _terminal = render_app(&mut app, /*width*/ 83, /*height*/ 8);
let (parent_area, side_area) = {
let parent = app
.chat_widget
.by_slot(PaneSlot::Parent)
.and_then(|pane| pane.owned_screen.as_ref())
.expect("parent screen")
.last_conversation_area;
let side = app
.chat_widget
.by_slot(PaneSlot::Side)
.and_then(|pane| pane.owned_screen.as_ref())
.expect("side screen")
.last_conversation_area;
(parent, side)
};
let mut tui = crate::tui::test_support::make_test_tui().expect("create test TUI");
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
primary_press(
parent_area.x,
parent_area.bottom().saturating_sub(/*rhs*/ 1),
),
));
assert!(app.handle_owned_screen_mouse_primary(
&mut tui,
primary_event(MousePrimaryEventKind::Drag, parent_area.x, parent_area.y,),
));
assert!(app.handle_owned_screen_mouse_scroll(
&mut tui,
MouseScrollEvent {
direction: MouseScrollDirection::Up,
column: side_area.x,
row: side_area.y,
},
));
assert!(!is_following_bottom(&app, PaneSlot::Parent));
assert!(is_following_bottom(&app, PaneSlot::Side));
assert!(
app.chat_widget
.by_slot(PaneSlot::Parent)
.and_then(|pane| pane.owned_screen.as_ref())
.expect("parent screen")
.selection_is_active()
);
}
#[tokio::test]
async fn narrow_side_clears_parent_hit_area_before_wheel_routing() {
let mut app = app_with_owned_side().await;
+2 -2
View File
@@ -589,7 +589,7 @@ impl App {
let spans = vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
self.chat_widget.add_literal_history_lines(lines);
}
}
Err(err) => {
@@ -822,7 +822,7 @@ impl App {
vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
self.chat_widget.add_literal_history_lines(lines);
}
self.maybe_prompt_resume_paused_goal_after_resume(
app_server,
+65 -10
View File
@@ -53,6 +53,7 @@ use crate::bottom_pane::StatusSurfacePreviewData;
use crate::bottom_pane::StatusSurfacePreviewItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::bottom_pane::TerminalTitleSetupView;
use crate::conversation_selection::CellSelectionProjection;
use crate::diff_model::FileChange;
use crate::git_action_directives::parse_assistant_markdown;
use crate::legacy_core::config::Config;
@@ -782,6 +783,16 @@ pub(crate) struct ActiveCellRenderKey {
pub(crate) animation_tick: Option<u64>,
}
/// One width-specific active history cell prepared for the application-owned viewport.
///
/// Keeping active cells separate lets the viewport preserve history-cell spacing and selection
/// mappings instead of flattening all in-flight output into presentation-only terminal lines.
pub(crate) struct ActiveCellDisplaySnapshot {
pub(crate) lines: Vec<HyperlinkLine>,
pub(crate) selection_projection: Option<CellSelectionProjection>,
pub(crate) is_stream_continuation: bool,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum InterruptedTurnNoticeMode {
#[default]
@@ -1548,11 +1559,24 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn add_plain_history_lines(&mut self, lines: Vec<Line<'static>>) {
pub(crate) fn add_literal_history_lines(&mut self, lines: Vec<Line<'static>>) {
self.add_boxed_history(Box::new(PlainHistoryCell::new(lines)));
self.request_redraw();
}
pub(crate) fn add_semantic_history_lines(
&mut self,
lines: Vec<Line<'static>>,
selection_text: String,
first_row_prefix_columns: Vec<u16>,
) {
self.add_boxed_history(Box::new(
PlainHistoryCell::new(lines)
.with_selection_text(selection_text, first_row_prefix_columns),
));
self.request_redraw();
}
pub(crate) fn add_warning_message(&mut self, message: String) {
self.add_to_history(history_cell::new_warning_event(message));
self.request_redraw();
@@ -1577,7 +1601,11 @@ impl ChatWidget {
if let Some(hint) = resume_hint(Some(name), thread_id) {
line.extend([". To resume this session run ".into(), hint.cyan()]);
}
PlainHistoryCell::new(vec![line.into()])
let line = Line::from(line);
let rendered = history_cell::selection_text_from_lines(std::slice::from_ref(&line));
let selection_text = rendered.strip_prefix("• ").unwrap_or(&rendered).to_string();
PlainHistoryCell::new(vec![line])
.with_selection_text(selection_text, /*first_row_prefix_columns*/ vec![2])
}
/// Begin the asynchronous MCP inventory flow: show a loading spinner and
@@ -2000,19 +2028,26 @@ impl ChatWidget {
(!lines.is_empty()).then_some(lines)
}
/// Returns the active cells' main-viewport lines for a given terminal width.
pub(crate) fn active_cell_display_hyperlink_lines(
/// Returns the active history cells prepared for the application-owned viewport.
///
/// Each item retains its own selection projection and stream-continuation state so the
/// retained viewport can treat live output exactly like committed history cells.
pub(crate) fn active_cell_display_snapshots(
&self,
width: u16,
) -> Option<Vec<HyperlinkLine>> {
) -> Option<Vec<ActiveCellDisplaySnapshot>> {
let mode = self.history_render_mode();
let mut lines = Vec::new();
let mut snapshots = Vec::new();
let mut append = |cell: &dyn HistoryCell| {
let cell_lines = cell.display_hyperlink_lines_for_mode(width, mode);
if !cell_lines.is_empty() && !lines.is_empty() {
lines.push(HyperlinkLine::from(""));
let lines = cell.display_hyperlink_lines_for_mode(width, mode);
if lines.is_empty() {
return;
}
lines.extend(cell_lines);
snapshots.push(ActiveCellDisplaySnapshot {
lines,
selection_projection: cell.selection_contribution(width, mode).into_projection(),
is_stream_continuation: cell.is_stream_continuation(),
});
};
if let Some(cell) = self.transcript.active_cell.as_deref() {
append(cell);
@@ -2030,6 +2065,26 @@ impl ChatWidget {
if let Some(cell) = self.pending_rate_limit_reset_hint() {
append(cell);
}
(!snapshots.is_empty()).then_some(snapshots)
}
/// Returns the active cells' flattened main-viewport lines for a given terminal width.
///
/// The application-owned viewport uses [`Self::active_cell_display_snapshots`] so it can
/// preserve per-cell selection metadata. This compatibility view remains useful to tests and
/// callers that only need the rendered text.
#[cfg(test)]
pub(crate) fn active_cell_display_hyperlink_lines(
&self,
width: u16,
) -> Option<Vec<HyperlinkLine>> {
let mut lines = Vec::new();
for snapshot in self.active_cell_display_snapshots(width)? {
if !lines.is_empty() {
lines.push(HyperlinkLine::from(""));
}
lines.extend(snapshot.lines);
}
(!lines.is_empty()).then_some(lines)
}
+1 -1
View File
@@ -7,7 +7,7 @@ use crate::status::format_tokens_compact;
impl ChatWidget {
pub(crate) fn show_goal_summary(&mut self, goal: AppThreadGoal) {
self.add_plain_history_lines(goal_summary_lines(&goal));
self.add_literal_history_lines(goal_summary_lines(&goal));
}
pub(crate) fn show_goal_edit_prompt(&mut self, thread_id: ThreadId, goal: AppThreadGoal) {
+4 -1
View File
@@ -204,8 +204,11 @@ impl ChatWidget {
]
.into()
};
let rendered = history_cell::selection_text_from_lines(std::slice::from_ref(&line));
let selection_text = rendered.strip_prefix("• ").unwrap_or(&rendered).to_string();
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
PlainHistoryCell::new(vec![line]),
PlainHistoryCell::new(vec![line])
.with_selection_text(selection_text, /*first_row_prefix_columns*/ vec![2]),
)));
}
+15 -10
View File
@@ -442,27 +442,32 @@ impl ChatWidget {
}
self.bottom_pane.hide_status_indicator();
self.transcript.active_cell =
Some(Box::new(history_cell::StreamingAgentTailCell::new(
self.transcript.active_cell = Some(Box::new(
history_cell::StreamingAgentTailCell::new_source_backed(
tail_lines,
controller.tail_starts_stream(),
)));
controller.current_tail_selection_fragment(),
),
));
self.bump_active_cell_revision();
return;
}
if let Some(controller) = self.plan_stream_controller.as_ref() {
let tail_lines = controller.current_tail_display_lines();
if tail_lines.is_empty() {
let Some(tail) = controller.current_tail_display() else {
self.clear_active_stream_tail();
return;
}
};
self.bottom_pane.hide_status_indicator();
self.transcript.active_cell = Some(Box::new(history_cell::StreamingPlanTailCell::new(
tail_lines,
!controller.tail_starts_stream(),
)));
self.transcript.active_cell = Some(Box::new(
history_cell::StreamingPlanTailCell::new_source_backed(
tail.lines,
!controller.tail_starts_stream(),
tail.selection_fragment,
tail.body_line_range,
),
));
self.bump_active_cell_revision();
return;
}
@@ -3864,6 +3864,12 @@ async fn hidden_active_hook_does_not_add_transcript_separator() {
.active_cell_display_hyperlink_lines(/*width*/ 80)
.expect("active exec display lines");
assert_eq!(hidden_hook_display.len(), exec_only_display_line_count);
assert_eq!(
chat.active_cell_display_snapshots(/*width*/ 80)
.expect("active exec display snapshot")
.len(),
1
);
reveal_running_hooks(&mut chat);
let visible_hook_lines = chat
@@ -3884,6 +3890,17 @@ async fn hidden_active_hook_does_not_add_transcript_separator() {
),
"\n"
);
let visible_display_snapshots = chat
.active_cell_display_snapshots(/*width*/ 80)
.expect("active exec and hook display snapshots");
assert_eq!(visible_display_snapshots.len(), 2);
assert_eq!(
visible_display_snapshots
.last()
.and_then(|snapshot| snapshot.selection_projection.as_ref())
.map(crate::conversation_selection::CellSelectionProjection::text),
Some("Running PostToolUse hook: checking output policy")
);
}
#[tokio::test]
+20
View File
@@ -28,8 +28,12 @@ use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::history_cell::CompositeHistoryCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::SelectionContribution;
use crate::history_cell::plain_lines;
use crate::history_cell::selection_contribution_from_semantic_text;
use crate::history_cell::selection_text_from_lines;
pub(crate) use chart::TokenActivityView;
@@ -145,6 +149,22 @@ impl HistoryCell for TokenActivityHistoryCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
let lines = self.display_lines_for_mode(width, mode);
let text = lines
.iter()
.map(|line| {
selection_text_from_lines(std::slice::from_ref(line))
.trim_start()
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
selection_contribution_from_semantic_text(
text, lines, width, /*first_row_prefix_columns*/ 0,
)
}
}
impl ChatWidget {
+6 -3
View File
@@ -37,9 +37,12 @@ impl ChatWidget {
pub(super) fn log_websocket_timing_totals(&mut self, delta: RuntimeMetricsSummary) {
if let Some(label) = history_cell::runtime_metrics_label(delta.responses_api_summary()) {
self.add_plain_history_lines(vec![
vec!["• ".dim(), format!("WebSocket timing: {label}").dark_gray()].into(),
]);
let selection_text = format!("WebSocket timing: {label}");
self.add_semantic_history_lines(
vec![vec!["• ".dim(), selection_text.clone().dark_gray()].into()],
selection_text,
/*first_row_prefix_columns*/ vec![2],
);
}
}
+503 -61
View File
@@ -3,6 +3,7 @@
//! Mouse coordinates are layout data, not clipboard data. A projection maps rendered terminal
//! cells back to ranges in canonical semantic text; copied output is always sliced from that text.
use std::collections::BTreeMap;
use std::ops::Range;
use std::sync::Arc;
@@ -33,6 +34,14 @@ pub(crate) struct CellSelectionProjection {
text: Arc<str>,
rows: Vec<SelectionRow>,
separator_before: &'static str,
separator_before_is_explicit: bool,
}
/// One rendered child of a composed selection projection.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CellSelectionProjectionPart {
Selectable(CellSelectionProjection),
Transparent { row_count: usize },
}
#[derive(Clone, Debug)]
@@ -53,7 +62,22 @@ impl CellSelectionProjection {
width: u16,
first_row_prefix_columns: u16,
) -> Option<Self> {
if text.is_empty() || width == 0 || lines.is_empty() {
let prefix_columns = vec![first_row_prefix_columns; lines.len()];
Self::from_display_lines_with_prefixes(text, lines, width, &prefix_columns)
}
/// Builds a projection with presentation-only prefix widths for each logical display line.
///
/// Each prefix applies only to the first terminal row generated from its logical line because
/// Ratatui may add further rows while wrapping an overlong token.
pub(crate) fn from_display_lines_with_prefixes(
text: String,
lines: Vec<Line<'static>>,
width: u16,
prefix_columns: &[u16],
) -> Option<Self> {
if text.is_empty() || width == 0 || lines.is_empty() || lines.len() != prefix_columns.len()
{
return None;
}
@@ -61,7 +85,8 @@ impl CellSelectionProjection {
let mut semantic_cursor = 0;
let mut rows = Vec::new();
for line in lines {
for (line_index, line) in lines.into_iter().enumerate() {
let first_row_prefix_columns = prefix_columns[line_index];
let paragraph = Paragraph::new(line).wrap(Wrap { trim: false });
let height = paragraph
.line_count(width)
@@ -94,6 +119,22 @@ impl CellSelectionProjection {
continue;
}
if let Some((segments, next_column, next_semantic_cursor)) =
tab_expansion_segments(
&buffer,
row_index,
column,
width,
&semantic,
semantic_cursor,
)
{
row.segments.extend(segments);
column = next_column;
semantic_cursor = next_semantic_cursor;
continue;
}
let Some(source_index) =
matching_semantic_index(&semantic, semantic_cursor, symbol)
else {
@@ -120,11 +161,176 @@ impl CellSelectionProjection {
text: Arc::from(text),
rows,
separator_before: "\n\n",
separator_before_is_explicit: false,
})
}
/// Concatenates child projections while preserving presentation-only rows between them.
pub(crate) fn compose(
parts: Vec<CellSelectionProjectionPart>,
blank_rows_between: usize,
text_separator: &'static str,
) -> Option<Self> {
let mut text = String::new();
let mut rows = Vec::new();
let mut has_rendered_part = false;
let mut has_selectable_part = false;
for part in parts {
if has_rendered_part {
rows.resize(
rows.len().saturating_add(blank_rows_between),
SelectionRow::default(),
);
}
match part {
CellSelectionProjectionPart::Selectable(projection) => {
if has_selectable_part {
text.push_str(text_separator);
}
let byte_offset = text.len();
text.push_str(&projection.text);
rows.extend(projection.rows.into_iter().map(|mut row| {
for segment in &mut row.segments {
segment.bytes = segment.bytes.start.saturating_add(byte_offset)
..segment.bytes.end.saturating_add(byte_offset);
}
row
}));
has_selectable_part = true;
}
CellSelectionProjectionPart::Transparent { row_count } => {
rows.resize(
rows.len().saturating_add(row_count),
SelectionRow::default(),
);
}
}
has_rendered_part = true;
}
if !has_selectable_part {
return None;
}
Some(Self {
text: Arc::from(text),
rows,
separator_before: "\n\n",
separator_before_is_explicit: false,
})
}
/// Builds a projection from an explicit rendered-row mapping.
///
/// This is used for layouts such as wrapped tables, where rendered row-major order differs
/// from the canonical logical text order and a greedy text alignment cannot recover the
/// source ranges.
pub(crate) fn from_rows(text: String, rows: Vec<SelectionRow>) -> Option<Self> {
if text.is_empty() || rows.is_empty() {
return None;
}
let valid_segment = |segment: &SelectionSegment| {
segment.columns.start < segment.columns.end
&& segment.bytes.start < segment.bytes.end
&& segment.bytes.end <= text.len()
&& text.is_char_boundary(segment.bytes.start)
&& text.is_char_boundary(segment.bytes.end)
};
if rows
.iter()
.flat_map(|row| &row.segments)
.any(|segment| !valid_segment(segment))
|| rows.iter().all(|row| row.segments.is_empty())
{
return None;
}
Some(Self {
text: Arc::from(text),
rows,
separator_before: "\n\n",
separator_before_is_explicit: false,
})
}
/// Extracts rendered rows while retaining the semantic gap before their first source glyph.
///
/// Stable streaming cells can split one authored paragraph across several rendered-line
/// chunks. The next chunk owns the source interval after the previous rendered rows, including
/// an authored newline when one exists and no synthetic newline when the split was a soft wrap.
pub(crate) fn slice_rows(&self, row_range: Range<usize>) -> Option<Self> {
let row_start = row_range.start.min(self.rows.len());
let row_end = row_range.end.min(self.rows.len());
if row_start >= row_end {
return None;
}
let preceding_end = self.rows[..row_start]
.iter()
.flat_map(|row| &row.segments)
.map(|segment| segment.bytes.end)
.max()
.unwrap_or_default();
let selected_start = self.rows[row_start..row_end]
.iter()
.flat_map(|row| &row.segments)
.map(|segment| segment.bytes.start)
.min()?;
let selected_end = self.rows[row_start..row_end]
.iter()
.flat_map(|row| &row.segments)
.map(|segment| segment.bytes.end)
.max()?;
let text_start = if row_start == 0 {
0
} else {
preceding_end.min(selected_start)
};
let text_end = if row_end == self.rows.len() {
self.text.len()
} else {
selected_end
};
let text = self.text.get(text_start..text_end)?.to_string();
let mut rows = self.rows[row_start..row_end].to_vec();
for row in &mut rows {
row.segments.retain(|segment| {
segment.bytes.start >= text_start && segment.bytes.end <= text_end
});
for segment in &mut row.segments {
segment.bytes = segment.bytes.start.saturating_sub(text_start)
..segment.bytes.end.saturating_sub(text_start);
}
}
let mut projection = Self::from_rows(text, rows)?;
projection.separator_before = self.separator_before;
projection.separator_before_is_explicit = self.separator_before_is_explicit;
Some(projection)
}
/// Shifts source-mapped columns past presentation-only cell chrome.
pub(crate) fn with_column_offset(mut self, column_offset: u16) -> Self {
for row in &mut self.rows {
for segment in &mut row.segments {
segment.columns = segment.columns.start.saturating_add(column_offset)
..segment.columns.end.saturating_add(column_offset);
}
}
self
}
pub(crate) fn with_separator_before(mut self, separator_before: &'static str) -> Self {
self.separator_before = separator_before;
self.separator_before_is_explicit = true;
self
}
/// Applies the viewport separator unless the cell already owns its leading source boundary.
pub(crate) fn with_default_separator_before(mut self, separator_before: &'static str) -> Self {
if !self.separator_before_is_explicit {
self.separator_before = separator_before;
}
self
}
@@ -145,7 +351,7 @@ impl CellSelectionProjection {
})
}
pub(crate) fn closest_hit(&self, row: usize, column: u16) -> Option<Range<usize>> {
fn closest_hit(&self, row: usize, column: u16) -> Option<Range<usize>> {
if let Some(hit) = self.hit(row, column) {
return Some(hit);
}
@@ -176,30 +382,149 @@ impl CellSelectionProjection {
})
}
pub(crate) fn closest_hit_in_any_row(&self, row: usize, column: u16) -> Option<Range<usize>> {
self.rows
fn point_range(&self, row: usize, column: u16) -> Range<usize> {
if let Some(hit) = self.hit(row, column) {
return hit;
}
if let Some(hit) = self.closest_hit(row, column) {
return hit;
}
let preceding =
self.rows
.iter()
.enumerate()
.take(row)
.rev()
.find_map(|(row, selection_row)| {
selection_row
.segments
.last()
.map(|segment| (row, segment.bytes.end))
});
let following = self
.rows
.iter()
.enumerate()
.filter_map(|(candidate_row, selection_row)| {
let first = selection_row.segments.first()?;
let last = selection_row.segments.last()?;
let column_distance = if column < first.columns.start {
first.columns.start - column
} else if column >= last.columns.end {
column.saturating_sub(last.columns.end.saturating_sub(1))
} else {
0
};
Some((
(candidate_row.abs_diff(row), column_distance),
candidate_row,
))
})
.min_by_key(|(distance, _)| *distance)
.and_then(|(_, candidate_row)| self.closest_hit(candidate_row, column))
.skip(row.saturating_add(1))
.find_map(|(row, selection_row)| {
selection_row
.segments
.first()
.map(|segment| (row, segment.bytes.start))
});
let gap_start = preceding.map(|(_, bytes)| bytes).unwrap_or_default();
let gap_end = following.map(|(_, bytes)| bytes).unwrap_or(self.text.len());
if gap_start >= gap_end {
let boundary = gap_start.min(self.text.len());
return boundary..boundary;
}
let preceding_row = preceding.map(|(row, _)| row);
let newline_count = row.saturating_sub(
preceding_row
.map(|row| row.saturating_add(1))
.unwrap_or_default(),
) + usize::from(preceding_row.is_some());
let newline_boundaries = self.text[gap_start..gap_end]
.match_indices('\n')
.map(|(offset, newline)| gap_start + offset + newline.len())
.collect::<Vec<_>>();
let boundary = if newline_count == 0 {
gap_start
} else if let Some(boundary) = newline_boundaries.get(newline_count.saturating_sub(1)) {
*boundary
} else {
newline_boundaries.last().copied().unwrap_or(gap_start)
};
boundary..boundary
}
}
fn tab_expansion_segments(
buffer: &Buffer,
row: u16,
start_column: u16,
width: u16,
semantic: &[SemanticGrapheme<'_>],
semantic_cursor: usize,
) -> Option<(Vec<SelectionSegment>, u16, usize)> {
let source_run_start = semantic
.iter()
.enumerate()
.skip(semantic_cursor)
.find(|(_, grapheme)| !is_line_break(grapheme.text))?
.0;
let source_run = semantic
.iter()
.enumerate()
.skip(source_run_start)
.take_while(|(_, grapheme)| is_non_line_break_whitespace(grapheme.text))
.collect::<Vec<_>>();
let tab_count = source_run
.iter()
.filter(|(_, grapheme)| grapheme.text == "\t")
.count();
if tab_count == 0 {
return None;
}
let mut end_column = start_column;
while end_column < width {
let cell = &buffer[(end_column, row)];
let symbol = cell.symbol();
if cell.skip || symbol.is_empty() || !symbol.chars().all(char::is_whitespace) {
break;
}
let symbol_width = symbol.width().max(1).min(usize::from(u16::MAX)) as u16;
end_column = end_column.saturating_add(symbol_width).min(width);
}
let rendered_columns = usize::from(end_column.saturating_sub(start_column));
let ordinary_columns = source_run.len().saturating_sub(tab_count);
if rendered_columns < source_run.len() {
return None;
}
let tab_columns = rendered_columns.saturating_sub(ordinary_columns);
let base_tab_width = tab_columns / tab_count;
let wider_tabs = tab_columns % tab_count;
let mut seen_tabs = 0usize;
let mut column = start_column;
let mut segments = Vec::with_capacity(source_run.len());
for (_, grapheme) in &source_run {
let columns = if grapheme.text == "\t" {
let extra = usize::from(seen_tabs < wider_tabs);
seen_tabs = seen_tabs.saturating_add(1);
base_tab_width.saturating_add(extra)
} else {
1
};
let columns = u16::try_from(columns).unwrap_or(u16::MAX);
let next_column = column.saturating_add(columns).min(end_column);
segments.push(SelectionSegment {
columns: column..next_column,
bytes: grapheme.bytes.clone(),
});
column = next_column;
}
Some((
segments,
end_column,
source_run_start.saturating_add(source_run.len()),
))
}
fn is_line_break(grapheme: &str) -> bool {
grapheme
.chars()
.all(|character| matches!(character, '\n' | '\r'))
}
fn is_non_line_break_whitespace(grapheme: &str) -> bool {
grapheme
.chars()
.all(|character| character.is_whitespace() && !matches!(character, '\n' | '\r'))
}
fn semantic_graphemes(text: &str) -> Vec<SemanticGrapheme<'_>> {
text.grapheme_indices(/*is_extended*/ true)
.map(|(start, grapheme)| SemanticGrapheme {
@@ -218,12 +543,15 @@ fn matching_semantic_index(
if current.text == rendered {
return Some(cursor);
}
if rendered.chars().all(char::is_whitespace) {
return current
.text
.chars()
.all(|character| character.is_whitespace() && character != '\n' && character != '\r')
.then_some(cursor);
if is_non_line_break_whitespace(rendered) {
return semantic
.iter()
.enumerate()
.skip(cursor)
.find(|(_, grapheme)| !is_line_break(grapheme.text))
.and_then(|(index, grapheme)| {
is_non_line_break_whitespace(grapheme.text).then_some(index)
});
}
for (index, grapheme) in semantic.iter().enumerate().skip(cursor) {
@@ -237,10 +565,27 @@ fn matching_semantic_index(
None
}
#[derive(Clone, Debug, Eq, PartialEq)]
/// A mouse position in the retained conversation's scrollable content coordinates.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(crate) struct SelectionPoint {
pub(crate) cell: usize,
pub(crate) bytes: Range<usize>,
pub(crate) row: usize,
pub(crate) column: u16,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct SelectionCellLayout {
pub(crate) top: usize,
pub(crate) height: usize,
}
impl SelectionCellLayout {
fn bottom(&self) -> usize {
self.top.saturating_add(self.height)
}
fn intersects(&self, rows: Range<usize>) -> bool {
self.height > 0 && self.top < rows.end && self.bottom() > rows.start
}
}
#[derive(Default)]
@@ -252,7 +597,7 @@ pub(crate) struct ConversationSelection {
impl ConversationSelection {
pub(crate) fn start(&mut self, point: SelectionPoint) {
self.anchor = Some(point.clone());
self.anchor = Some(point);
self.focus = Some(point);
self.dragged = false;
}
@@ -270,11 +615,12 @@ impl ConversationSelection {
&mut self,
point: Option<SelectionPoint>,
projections: &[Option<CellSelectionProjection>],
layout: &[SelectionCellLayout],
) -> Option<String> {
self.set_release_point(point);
let text = self
.dragged
.then(|| self.selected_text(projections))
.then(|| self.selected_text(projections, layout))
.flatten();
self.cancel();
text.filter(|text| !text.is_empty())
@@ -296,47 +642,143 @@ impl ConversationSelection {
self.anchor.is_some()
}
pub(crate) fn selected_cell_span(&self) -> Option<Range<usize>> {
let (start, end) = self.ordered_points()?;
Some(start.cell..end.cell.saturating_add(1))
}
pub(crate) fn selected_bytes_for_cell(
pub(crate) fn selected_cell_span(
&self,
cell: usize,
text_len: usize,
layout: &[SelectionCellLayout],
) -> Option<Range<usize>> {
let (start, end) = self.ordered_points()?;
if cell < start.cell || cell > end.cell {
return None;
}
let range = if start.cell == end.cell {
start.bytes.start..end.bytes.end
} else if cell == start.cell {
start.bytes.start..text_len
} else if cell == end.cell {
0..end.bytes.end
} else {
0..text_len
};
(range.start < range.end).then_some(range)
let selected_rows = start.row..end.row.saturating_add(1);
let mut selected = layout
.iter()
.enumerate()
.filter(|(_, cell)| cell.intersects(selected_rows.clone()))
.map(|(index, _)| index);
let first = selected.next()?;
let last = selected.next_back().unwrap_or(first);
Some(first..last.saturating_add(1))
}
fn selected_text(&self, projections: &[Option<CellSelectionProjection>]) -> Option<String> {
pub(crate) fn segment_is_selected(
&self,
layout: SelectionCellLayout,
row: usize,
columns: &Range<u16>,
) -> bool {
let Some((start, end)) = self.ordered_points() else {
return false;
};
let content_row = layout.top.saturating_add(row);
if content_row < start.row || content_row > end.row {
return false;
}
let starts_before_end = content_row < end.row || columns.start <= end.column;
let ends_after_start = content_row > start.row || columns.end > start.column;
starts_before_end && ends_after_start
}
fn selected_text_for_cell(
&self,
projection: &CellSelectionProjection,
layout: SelectionCellLayout,
) -> Option<String> {
let mut mapped = BTreeMap::<(usize, usize), bool>::new();
for (row_index, row) in projection.rows().iter().enumerate() {
for segment in &row.segments {
let selected = self.segment_is_selected(layout, row_index, &segment.columns);
mapped
.entry((segment.bytes.start, segment.bytes.end))
.and_modify(|was_selected| *was_selected |= selected)
.or_insert(selected);
}
}
let mut runs = Vec::<Range<usize>>::new();
let mut current: Option<Range<usize>> = None;
for ((start, end), selected) in mapped {
if selected {
if let Some(run) = current.as_mut() {
run.end = run.end.max(end);
} else {
current = Some(start..end);
}
} else if let Some(run) = current.take() {
runs.push(run);
}
}
if let Some(run) = current {
runs.push(run);
}
let (start, end) = self.ordered_points()?;
if let Some(first) = runs.first_mut() {
let boundary = if start.row < layout.top {
0
} else {
projection
.point_range(start.row.saturating_sub(layout.top), start.column)
.start
};
if boundary <= first.start
&& projection
.text()
.get(boundary..first.start)
.is_some_and(|gap| gap.chars().all(char::is_whitespace))
{
first.start = boundary;
}
}
if let Some(last) = runs.last_mut() {
let boundary = if end.row >= layout.bottom() {
projection.text().len()
} else {
projection
.point_range(end.row.saturating_sub(layout.top), end.column)
.end
};
if boundary >= last.end
&& projection
.text()
.get(last.end..boundary)
.is_some_and(|gap| gap.chars().all(char::is_whitespace))
{
last.end = boundary;
}
}
let mut selected = String::new();
for cell in start.cell..=end.cell {
let projection = projections.get(cell)?.as_ref()?;
let Some(range) = self.selected_bytes_for_cell(cell, projection.text().len()) else {
for run in runs {
let Some(fragment) = projection.text().get(run) else {
continue;
};
let Some(fragment) = projection.text().get(range) else {
if !selected.is_empty() {
selected.push('\n');
}
selected.push_str(fragment);
}
(!selected.is_empty()).then_some(selected)
}
fn selected_text(
&self,
projections: &[Option<CellSelectionProjection>],
layout: &[SelectionCellLayout],
) -> Option<String> {
let selected_cells = self.selected_cell_span(layout)?;
let mut selected = String::new();
for cell in selected_cells {
let Some(projection) = projections.get(cell).and_then(Option::as_ref) else {
continue;
};
let Some(fragment) = layout
.get(cell)
.and_then(|layout| self.selected_text_for_cell(projection, *layout))
else {
continue;
};
if !selected.is_empty() {
selected.push_str(projection.separator_before);
}
selected.push_str(fragment);
selected.push_str(&fragment);
}
Some(selected)
}
@@ -344,7 +786,7 @@ impl ConversationSelection {
fn ordered_points(&self) -> Option<(&SelectionPoint, &SelectionPoint)> {
let anchor = self.anchor.as_ref()?;
let focus = self.focus.as_ref()?;
if (anchor.cell, anchor.bytes.start) <= (focus.cell, focus.bytes.start) {
if anchor <= focus {
Some((anchor, focus))
} else {
Some((focus, anchor))
+367 -29
View File
@@ -19,19 +19,37 @@ fn projection(
}
fn point(
projection: &CellSelectionProjection,
_projection: &CellSelectionProjection,
cell: usize,
row: usize,
column: u16,
) -> SelectionPoint {
SelectionPoint {
cell,
bytes: projection
.hit(row, column)
.expect("coordinate should map to source text"),
row: cell.saturating_add(row),
column,
}
}
fn finish(
selection: &mut ConversationSelection,
projections: Vec<Option<CellSelectionProjection>>,
) -> Option<String> {
let mut top = 0;
let layout = projections
.iter()
.map(|projection| {
let height = projection
.as_ref()
.map(|projection| projection.rows().len())
.unwrap_or(1);
let cell = SelectionCellLayout { top, height };
top = top.saturating_add(height);
cell
})
.collect::<Vec<_>>();
selection.finish(/*point*/ None, &projections, &layout)
}
#[test]
fn soft_wrapped_selection_returns_original_space_without_newline() {
let projection = projection(
@@ -55,7 +73,7 @@ fn soft_wrapped_selection_returns_original_space_without_newline() {
));
assert_eq!(
selection.finish(/*point*/ None, &[Some(projection)]),
finish(&mut selection, vec![Some(projection)]),
Some("alpha beta".to_string())
);
}
@@ -83,11 +101,47 @@ fn authored_newline_is_preserved_across_rendered_rows() {
));
assert_eq!(
selection.finish(/*point*/ None, &[Some(projection)]),
finish(&mut selection, vec![Some(projection)]),
Some("alpha\nbeta".to_string())
);
}
#[test]
fn forward_selection_can_start_in_spaces_after_an_authored_newline() {
let projection = projection(
"alpha\n beta",
["alpha", " beta"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint { row: 1, column: 0 });
selection.update(SelectionPoint { row: 1, column: 5 });
assert_eq!(
finish(&mut selection, vec![Some(projection)]),
Some(" beta".to_string())
);
}
#[test]
fn backward_selection_can_end_in_a_tab_after_an_authored_newline() {
let projection = projection(
"alpha\n\tbeta",
["alpha", " beta"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint { row: 1, column: 7 });
selection.update(SelectionPoint { row: 1, column: 2 });
assert_eq!(
finish(&mut selection, vec![Some(projection)]),
Some("\tbeta".to_string())
);
}
#[test]
fn backward_drag_normalizes_to_the_same_source_range() {
let projection = projection(
@@ -111,7 +165,7 @@ fn backward_drag_normalizes_to_the_same_source_range() {
));
assert_eq!(
selection.finish(/*point*/ None, &[Some(projection)]),
finish(&mut selection, vec![Some(projection)]),
Some("alpha beta".to_string())
);
}
@@ -139,11 +193,29 @@ fn tabs_are_copied_from_source_instead_of_visual_spaces() {
));
assert_eq!(
selection.finish(/*point*/ None, &[Some(projection)]),
finish(&mut selection, vec![Some(projection)]),
Some("a\tb".to_string())
);
}
#[test]
fn every_visual_column_of_a_tab_expansion_maps_to_the_authored_tab() {
let projection = projection(
"a\tb",
["a b"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
for column in 1..5 {
assert_eq!(
projection.hit(/*row*/ 0, column),
Some(1..2),
"tab expansion column {column} should select the tab byte"
);
}
}
#[test]
fn wide_and_combining_graphemes_map_as_complete_utf8_ranges() {
let projection = projection(
@@ -175,6 +247,250 @@ fn presentation_prefix_and_padding_have_no_source_mapping() {
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 8), None);
}
#[test]
fn per_line_prefixes_exclude_each_lines_chrome() {
let projection = CellSelectionProjection::from_display_lines_with_prefixes(
"alpha beta".to_string(),
vec!["› alpha".into(), " beta".into()],
/*width*/ 20,
/*prefix_columns*/ &[2, 4],
)
.expect("projection should contain selectable text");
assert_eq!(projection.hit(/*row*/ 1, /*column*/ 3), None);
assert_eq!(
projection.hit(/*row*/ 1, /*column*/ 4),
Some("alpha ".len().."alpha b".len())
);
}
#[test]
fn per_line_prefixes_require_one_width_per_display_line() {
assert_eq!(
CellSelectionProjection::from_display_lines_with_prefixes(
"text".to_string(),
vec!["text".into()],
/*width*/ 20,
/*prefix_columns*/ &[],
),
None
);
}
#[test]
fn selection_can_begin_before_the_first_glyph() {
let projection = projection(
"hello",
["› hello"],
/*width*/ 20,
/*prefix_columns*/ 2,
);
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint { row: 0, column: 0 });
selection.update(SelectionPoint { row: 0, column: 19 });
assert_eq!(
finish(&mut selection, vec![Some(projection)]),
Some("hello".to_string())
);
}
#[test]
fn backward_selection_can_begin_after_the_last_glyph() {
let projection = projection(
"hello",
["› hello"],
/*width*/ 20,
/*prefix_columns*/ 2,
);
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint { row: 0, column: 19 });
selection.update(SelectionPoint { row: 0, column: 3 });
assert_eq!(
finish(&mut selection, vec![Some(projection)]),
Some("ello".to_string())
);
}
#[test]
fn authored_blank_row_maps_to_its_semantic_boundary() {
let projection = projection(
"alpha\n\nbeta",
["alpha", "", "beta"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint { row: 1, column: 10 });
selection.update(SelectionPoint { row: 2, column: 3 });
assert_eq!(
finish(&mut selection, vec![Some(projection)]),
Some("\nbeta".to_string())
);
}
#[test]
fn horizontal_drag_on_an_authored_blank_row_copies_nothing() {
let projection = projection(
"alpha\n\nbeta",
["alpha", "", "beta"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint { row: 1, column: 0 });
selection.update(SelectionPoint { row: 1, column: 19 });
assert_eq!(finish(&mut selection, vec![Some(projection)]), None);
}
#[test]
fn blank_row_between_non_monotonic_segments_uses_a_safe_visual_boundary() {
let projection = CellSelectionProjection::from_rows(
"abcd".to_string(),
vec![
SelectionRow {
segments: vec![SelectionSegment {
columns: 0..1,
bytes: 2..3,
}],
},
SelectionRow::default(),
SelectionRow {
segments: vec![SelectionSegment {
columns: 0..1,
bytes: 0..1,
}],
},
],
)
.expect("non-monotonic projection should be valid");
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint { row: 1, column: 10 });
selection.update(SelectionPoint { row: 2, column: 0 });
assert_eq!(
finish(&mut selection, vec![Some(projection)]),
Some("a".to_string())
);
}
#[test]
fn selection_ending_on_a_blank_row_stops_at_that_rows_start() {
let projection = projection(
"alpha\n\nbeta",
["alpha", "", "beta"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint { row: 0, column: 0 });
selection.update(SelectionPoint { row: 1, column: 19 });
assert_eq!(
finish(&mut selection, vec![Some(projection)]),
Some("alpha\n".to_string())
);
}
#[test]
fn leading_and_trailing_blank_rows_preserve_authored_newlines() {
let leading = projection(
"\nalpha",
["", "alpha"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let mut leading_selection = ConversationSelection::default();
leading_selection.start(SelectionPoint { row: 0, column: 10 });
leading_selection.update(SelectionPoint { row: 1, column: 4 });
assert_eq!(
finish(&mut leading_selection, vec![Some(leading)]),
Some("\nalpha".to_string())
);
let trailing = projection(
"alpha\n",
["alpha", ""],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let mut trailing_selection = ConversationSelection::default();
trailing_selection.start(SelectionPoint { row: 0, column: 0 });
trailing_selection.update(SelectionPoint { row: 1, column: 10 });
assert_eq!(
finish(&mut trailing_selection, vec![Some(trailing)]),
Some("alpha\n".to_string())
);
}
#[test]
fn selection_can_begin_in_an_inter_cell_gap() {
let first = projection(
"first",
["first"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let second = projection(
"second",
["second"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let layout = [
SelectionCellLayout { top: 0, height: 1 },
SelectionCellLayout { top: 2, height: 1 },
];
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint { row: 1, column: 10 });
selection.update(SelectionPoint { row: 2, column: 5 });
assert_eq!(
selection.finish(/*point*/ None, &[Some(first), Some(second)], &layout),
Some("second".to_string())
);
}
#[test]
fn backward_selection_can_begin_below_the_content() {
let projection = projection(
"hello",
["hello"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let layout = [SelectionCellLayout { top: 0, height: 1 }];
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint {
row: 10,
column: 19,
});
selection.update(SelectionPoint { row: 0, column: 0 });
assert_eq!(
selection.finish(/*point*/ None, &[Some(projection)], &layout),
Some("hello".to_string())
);
}
#[test]
fn drag_entirely_within_padding_copies_nothing() {
let projection = projection(
"hello",
["hello"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint { row: 0, column: 10 });
selection.update(SelectionPoint { row: 0, column: 19 });
assert_eq!(finish(&mut selection, vec![Some(projection)]), None);
}
#[test]
fn soft_wrap_padding_does_not_consume_an_invisible_source_space() {
let projection = projection(
@@ -192,15 +508,10 @@ fn soft_wrap_padding_does_not_consume_an_invisible_source_space() {
/*row*/ 0,
/*column*/ 0,
));
selection.update(SelectionPoint {
cell: 0,
bytes: projection
.closest_hit(/*row*/ 0, /*column*/ 7)
.expect("padding should clamp to the last rendered grapheme"),
});
selection.update(SelectionPoint { row: 0, column: 7 });
assert_eq!(
selection.finish(/*point*/ None, &[Some(projection)]),
finish(&mut selection, vec![Some(projection)]),
Some("alpha".to_string())
);
}
@@ -220,15 +531,10 @@ fn clamping_to_a_wrapped_prefix_stops_before_the_first_source_glyph() {
/*row*/ 0,
/*column*/ 2,
));
selection.update(SelectionPoint {
cell: 0,
bytes: projection
.closest_hit(/*row*/ 1, /*column*/ 0)
.expect("the prefix should clamp to the boundary before beta"),
});
selection.update(SelectionPoint { row: 1, column: 0 });
assert_eq!(
selection.finish(/*point*/ None, &[Some(projection)]),
finish(&mut selection, vec![Some(projection)]),
Some("alpha ".to_string())
);
}
@@ -277,7 +583,7 @@ fn cross_cell_selection_uses_explicit_message_separator() {
));
assert_eq!(
selection.finish(/*point*/ None, &[Some(first), Some(second)]),
finish(&mut selection, vec![Some(first), Some(second)]),
Some("first\n\nsecond".to_string())
);
}
@@ -306,13 +612,45 @@ fn stream_continuation_uses_a_single_authored_line_break() {
));
assert_eq!(
selection.finish(/*point*/ None, &[Some(first), Some(second)]),
finish(&mut selection, vec![Some(first), Some(second)]),
Some("first\nsecond".to_string())
);
}
#[test]
fn selection_does_not_silently_skip_an_unsupported_cell() {
fn source_fragment_separator_does_not_add_text_between_stream_chunks() {
for (second_text, expected) in [(" beta", "alpha beta"), ("\nbeta", "alpha\nbeta")] {
let first = projection(
"alpha",
["alpha"],
/*width*/ 20,
/*prefix_columns*/ 0,
);
let second = projection(
second_text,
["beta"],
/*width*/ 20,
/*prefix_columns*/ 0,
)
.with_separator_before("")
.with_default_separator_before("\n");
let mut selection = ConversationSelection::default();
selection.start(point(
&first, /*cell*/ 0, /*row*/ 0, /*column*/ 0,
));
selection.update(point(
&second, /*cell*/ 1, /*row*/ 0, /*column*/ 3,
));
assert_eq!(
finish(&mut selection, vec![Some(first), Some(second)]).as_deref(),
Some(expected)
);
}
}
#[test]
fn transparent_cell_is_skipped_without_losing_the_next_separator() {
let first = projection(
"first",
["first"],
@@ -334,8 +672,8 @@ fn selection_does_not_silently_skip_an_unsupported_cell() {
));
assert_eq!(
selection.finish(/*point*/ None, &[Some(first), None, Some(third)],),
None
finish(&mut selection, vec![Some(first), None, Some(third)]),
Some("first\n\nthird".to_string())
);
}
@@ -355,5 +693,5 @@ fn click_without_drag_does_not_copy() {
/*column*/ 0,
));
assert_eq!(selection.finish(/*point*/ None, &[Some(projection)]), None);
assert_eq!(finish(&mut selection, vec![Some(projection)]), None);
}
+179 -158
View File
@@ -18,9 +18,11 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use crate::chatwidget::ActiveCellDisplaySnapshot;
use crate::chatwidget::ActiveCellRenderKey;
use crate::conversation_selection::CellSelectionProjection;
use crate::conversation_selection::ConversationSelection;
use crate::conversation_selection::SelectionCellLayout;
use crate::conversation_selection::SelectionPoint;
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
@@ -40,8 +42,10 @@ pub(crate) struct ConversationViewport {
cells: Vec<Arc<dyn HistoryCell>>,
render_mode: HistoryRenderMode,
live_tail_key: Option<LiveTailKey>,
live_cells: Vec<ActiveCellDisplaySnapshot>,
deferred_cells: Option<Vec<Arc<dyn HistoryCell>>>,
deferred_render_mode: Option<HistoryRenderMode>,
selection: ConversationSelection,
selection_scroll_offset: Option<usize>,
selection_projection_cache: Option<SelectionProjectionCache>,
}
@@ -53,12 +57,6 @@ struct SelectionProjectionCache {
layout: Vec<SelectionCellLayout>,
}
#[derive(Clone, Copy)]
struct SelectionCellLayout {
top: usize,
height: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct LiveTailKey {
width: u16,
@@ -79,8 +77,10 @@ impl ConversationViewport {
cells,
render_mode,
live_tail_key: None,
live_cells: Vec::new(),
deferred_cells: None,
deferred_render_mode: None,
selection: ConversationSelection::default(),
selection_scroll_offset: None,
selection_projection_cache: None,
}
}
@@ -92,13 +92,6 @@ impl ConversationViewport {
BottomFollowMode::Enabled
};
self.content.render(area, buf, bottom_follow);
if self.selection.is_active()
&& self
.selection_scroll_offset
.is_some_and(|offset| offset != self.content.scroll_offset())
{
self.cancel_selection();
}
if self.selection_projection_cache.is_some() {
self.ensure_selection_projections(area.width);
self.ensure_selected_projections();
@@ -118,12 +111,21 @@ impl ConversationViewport {
self.content.handle_mouse_scroll(direction);
}
pub(crate) fn handle_selection_mouse_scroll(
&mut self,
area: Rect,
direction: MouseScrollDirection,
position: Position,
) {
self.content.handle_mouse_scroll(direction);
self.update_selection(area, position);
}
pub(crate) fn begin_selection(&mut self, area: Rect, position: Position) -> bool {
let Some(point) = self.selection_point(area, position, /*clamp*/ false) else {
return false;
};
self.selection.start(point);
self.selection_scroll_offset = Some(self.content.scroll_offset());
true
}
@@ -138,19 +140,19 @@ impl ConversationViewport {
let point = self.selection_point(area, position, /*clamp*/ true);
self.selection.set_release_point(point);
self.ensure_selected_projections();
let projections = self
.selection_projection_cache
.as_ref()
.map(|cache| cache.projections.as_slice())
.unwrap_or_default();
let selected = self.selection.finish(/*point*/ None, projections);
self.selection_scroll_offset = None;
let selected = if let Some(cache) = self.selection_projection_cache.as_ref() {
self.selection
.finish(/*point*/ None, &cache.projections, &cache.layout)
} else {
self.selection.finish(/*point*/ None, &[], &[])
};
self.apply_deferred_state();
selected
}
pub(crate) fn cancel_selection(&mut self) {
self.selection.cancel();
self.selection_scroll_offset = None;
self.apply_deferred_state();
}
pub(crate) fn selection_is_active(&self) -> bool {
@@ -158,10 +160,16 @@ impl ConversationViewport {
}
pub(crate) fn push_cell(&mut self, cell: Arc<dyn HistoryCell>) {
if self.selection.is_active() {
self.deferred_cells
.get_or_insert_with(|| self.cells.clone())
.push(cell);
return;
}
self.invalidate_selection_projections();
let follow_bottom = self.content.is_following_bottom();
let had_prior_cells = !self.cells.is_empty();
let tail_renderable = self.take_live_tail_renderable();
self.take_live_tail_renderables();
let renderable = Self::cell_renderable(
cell.clone(),
self.render_mode,
@@ -169,29 +177,22 @@ impl ConversationViewport {
);
self.cells.push(cell);
self.content.push(renderable);
if let Some(tail) = tail_renderable {
let tail = if !had_prior_cells
&& self
.live_tail_key
.is_some_and(|key| !key.is_stream_continuation)
{
Self::with_leading_spacing(tail)
} else {
tail
};
self.content.push(tail);
}
self.push_live_tail_renderables();
if follow_bottom {
self.content.scroll_to_bottom();
}
}
pub(crate) fn replace_cells(&mut self, cells: Vec<Arc<dyn HistoryCell>>) {
if self.selection.is_active() {
self.deferred_cells = Some(cells);
return;
}
self.invalidate_selection_projections();
let follow_bottom = self.content.is_following_bottom();
self.take_live_tail_renderable();
self.take_live_tail_renderables();
self.live_tail_key = None;
self.live_cells.clear();
self.cells = cells;
self.content
.replace(Self::render_cells(&self.cells, self.render_mode));
@@ -201,13 +202,19 @@ impl ConversationViewport {
}
pub(crate) fn set_render_mode(&mut self, render_mode: HistoryRenderMode) {
if self.render_mode == render_mode {
let effective_render_mode = self.deferred_render_mode.unwrap_or(self.render_mode);
if effective_render_mode == render_mode {
return;
}
if self.selection.is_active() {
self.deferred_render_mode = Some(render_mode);
return;
}
self.invalidate_selection_projections();
let follow_bottom = self.content.is_following_bottom();
self.take_live_tail_renderable();
self.take_live_tail_renderables();
self.live_tail_key = None;
self.live_cells.clear();
self.render_mode = render_mode;
self.content
.replace(Self::render_cells(&self.cells, self.render_mode));
@@ -220,8 +227,21 @@ impl ConversationViewport {
&mut self,
width: u16,
active_key: Option<ActiveCellRenderKey>,
compute_lines: impl FnOnce(u16) -> Option<Vec<HyperlinkLine>>,
compute_cells: impl FnOnce(u16) -> Option<Vec<ActiveCellDisplaySnapshot>>,
) {
// A drag's screen coordinates and source projections must describe the same immutable
// content. Active cells can mutate on every output delta or animation tick, so defer all
// live-tail changes until the drag completes or is cancelled.
if self.selection.is_active() {
let width_is_stable = self
.selection_projection_cache
.as_ref()
.is_some_and(|cache| cache.width == width);
if width_is_stable {
return;
}
self.cancel_selection();
}
let next_key = active_key.map(|key| LiveTailKey {
width,
revision: key.revision,
@@ -232,18 +252,14 @@ impl ConversationViewport {
return;
}
let follow_bottom = !self.selection.is_active() && self.content.is_following_bottom();
self.take_live_tail_renderable();
let follow_bottom = self.content.is_following_bottom();
self.take_live_tail_renderables();
self.selection_projection_cache = None;
self.live_tail_key = next_key;
if let Some(key) = next_key {
let lines = compute_lines(width).unwrap_or_default();
if !lines.is_empty() {
self.content.push(Self::live_tail_renderable(
lines,
!self.cells.is_empty(),
key.is_stream_continuation,
));
}
self.live_cells.clear();
if next_key.is_some() {
self.live_cells = compute_cells(width).unwrap_or_default();
self.push_live_tail_renderables();
}
if follow_bottom {
self.content.scroll_to_bottom();
@@ -308,6 +324,18 @@ impl ConversationViewport {
}
}
fn push_live_tail_renderables(&mut self) {
let mut has_prior_cells = !self.cells.is_empty();
for cell in &self.live_cells {
self.content.push(Self::live_tail_renderable(
cell.lines.clone(),
has_prior_cells,
cell.is_stream_continuation,
));
has_prior_cells = true;
}
}
fn with_leading_spacing(renderable: Box<dyn Renderable>) -> Box<dyn Renderable> {
Box::new(InsetRenderable::new(
renderable,
@@ -317,8 +345,10 @@ impl ConversationViewport {
))
}
fn take_live_tail_renderable(&mut self) -> Option<Box<dyn Renderable>> {
(self.content.len() > self.cells.len()).then(|| self.content.pop())?
fn take_live_tail_renderables(&mut self) {
while self.content.len() > self.cells.len() {
self.content.pop();
}
}
fn invalidate_selection_projections(&mut self) {
@@ -326,49 +356,99 @@ impl ConversationViewport {
self.selection_projection_cache = None;
}
fn apply_deferred_state(&mut self) {
let deferred_cells = self.deferred_cells.take();
let deferred_render_mode = self.deferred_render_mode.take();
if deferred_cells.is_none() && deferred_render_mode.is_none() {
return;
}
let follow_bottom = self.content.is_following_bottom();
self.take_live_tail_renderables();
self.live_tail_key = None;
self.live_cells.clear();
if let Some(cells) = deferred_cells {
self.cells = cells;
}
if let Some(render_mode) = deferred_render_mode {
self.render_mode = render_mode;
}
self.content
.replace(Self::render_cells(&self.cells, self.render_mode));
self.selection_projection_cache = None;
if follow_bottom {
self.content.scroll_to_bottom();
}
}
fn ensure_selection_projections(&mut self, width: u16) {
let current_cell_count = self.cells.len().saturating_add(self.live_cells.len());
let cache_matches = self
.selection_projection_cache
.as_ref()
.is_some_and(|cache| {
cache.width == width
&& cache.render_mode == self.render_mode
&& cache.projections.len() == self.cells.len()
&& cache.projections.len() == current_cell_count
});
if cache_matches {
return;
}
self.cancel_selection();
let selection_cell_count = self.cells.len().saturating_add(self.live_cells.len());
let renderable_heights = self.content.renderable_heights(width);
let mut content_top = 0usize;
let layout = self
.cells
.iter()
.enumerate()
.map(|(index, cell)| {
let leading_spacing = usize::from(index > 0 && !cell.is_stream_continuation());
let renderable_height = renderable_heights
.get(index)
.copied()
.map(usize::from)
.unwrap_or_else(|| {
leading_spacing.saturating_add(usize::from(
cell.desired_height_for_mode(width, self.render_mode),
))
});
let cell_layout = SelectionCellLayout {
top: content_top.saturating_add(leading_spacing),
height: renderable_height.saturating_sub(leading_spacing),
let mut layout = Vec::with_capacity(selection_cell_count);
for index in 0..selection_cell_count {
let (is_stream_continuation, desired_height) = if let Some(cell) = self.cells.get(index)
{
(
cell.is_stream_continuation(),
cell.desired_height_for_mode(width, self.render_mode),
)
} else {
let Some(cell) = self.live_cells.get(index.saturating_sub(self.cells.len())) else {
return;
};
content_top = content_top.saturating_add(renderable_height);
cell_layout
})
.collect();
(
cell.is_stream_continuation,
HyperlinkLinesRenderable {
lines: cell.lines.clone(),
}
.desired_height(width),
)
};
let leading_spacing = usize::from(index > 0 && !is_stream_continuation);
let renderable_height = renderable_heights
.get(index)
.copied()
.unwrap_or(desired_height);
layout.push(SelectionCellLayout {
top: content_top.saturating_add(leading_spacing),
height: usize::from(renderable_height).saturating_sub(leading_spacing),
});
content_top = content_top.saturating_add(usize::from(renderable_height));
}
let mut projections = vec![None; selection_cell_count];
let mut computed = vec![false; selection_cell_count];
for (live_index, cell) in self.live_cells.iter().enumerate() {
let index = self.cells.len().saturating_add(live_index);
let separator = if index > 0 && cell.is_stream_continuation {
"\n"
} else {
"\n\n"
};
projections[index] = cell
.selection_projection
.clone()
.map(|projection| projection.with_default_separator_before(separator));
computed[index] = true;
}
self.selection_projection_cache = Some(SelectionProjectionCache {
width,
render_mode: self.render_mode,
projections: vec![None; self.cells.len()],
computed: vec![false; self.cells.len()],
projections,
computed,
layout,
});
}
@@ -394,8 +474,9 @@ impl ConversationViewport {
"\n\n"
};
let projection = cell
.selection_projection(cache.width, cache.render_mode)
.map(|projection| projection.with_separator_before(separator));
.selection_contribution(cache.width, cache.render_mode)
.into_projection()
.map(|projection| projection.with_default_separator_before(separator));
if let Some(cache) = self.selection_projection_cache.as_mut() {
cache.projections[index] = projection;
cache.computed[index] = true;
@@ -403,7 +484,14 @@ impl ConversationViewport {
}
fn ensure_selected_projections(&mut self) {
let Some(selected_cells) = self.selection.selected_cell_span() else {
let Some(layout) = self
.selection_projection_cache
.as_ref()
.map(|cache| cache.layout.as_slice())
else {
return;
};
let Some(selected_cells) = self.selection.selected_cell_span(layout) else {
return;
};
for index in selected_cells {
@@ -436,79 +524,17 @@ impl ConversationViewport {
.content
.scroll_offset()
.saturating_add(usize::from(screen_row));
let target = self
.selection_projection_cache
.as_ref()?
.layout
.iter()
.position(|layout| {
layout.height > 0
&& content_row >= layout.top
&& content_row < layout.top.saturating_add(layout.height)
});
if let Some(index) = target {
self.ensure_cell_projection(index);
let cache = self.selection_projection_cache.as_ref()?;
let layout = cache.layout[index];
let projection = cache.projections[index].as_ref();
let local_row = content_row.saturating_sub(layout.top);
let bytes = projection.and_then(|projection| {
if clamp {
projection
.closest_hit(local_row, column)
.or_else(|| projection.closest_hit_in_any_row(local_row, column))
} else {
projection.hit(local_row, column)
}
});
if let Some(bytes) = bytes {
return Some(SelectionPoint { cell: index, bytes });
}
}
if !clamp {
return None;
}
let mut candidates = self
.selection_projection_cache
.as_ref()?
.layout
.iter()
.enumerate()
.filter(|(index, layout)| Some(*index) != target && layout.height > 0)
.map(|(index, layout)| {
let distance = if content_row < layout.top {
layout.top - content_row
} else {
content_row
.saturating_sub(layout.top.saturating_add(layout.height).saturating_sub(1))
};
(distance, index)
})
.collect::<Vec<_>>();
candidates.sort_unstable();
for (_, index) in candidates {
self.ensure_cell_projection(index);
let cache = self.selection_projection_cache.as_ref()?;
let layout = cache.layout[index];
let local_row = content_row
.saturating_sub(layout.top)
.min(layout.height.saturating_sub(1));
if let Some(bytes) = cache.projections[index]
.as_ref()
.and_then(|projection| projection.closest_hit_in_any_row(local_row, column))
{
return Some(SelectionPoint { cell: index, bytes });
}
}
None
Some(SelectionPoint {
row: content_row,
column,
})
}
fn render_selection(&self, area: Rect, buf: &mut Buffer) {
let Some(cache) = self.selection_projection_cache.as_ref() else {
return;
};
let Some(selected_cells) = self.selection.selected_cell_span() else {
let Some(selected_cells) = self.selection.selected_cell_span(&cache.layout) else {
return;
};
let scroll_offset = self.content.scroll_offset();
@@ -520,12 +546,6 @@ impl ConversationViewport {
else {
continue;
};
let Some(selected_bytes) = self
.selection
.selected_bytes_for_cell(cell_index, projection.text().len())
else {
continue;
};
for (row_index, row) in projection.rows().iter().enumerate() {
let content_row = layout.top.saturating_add(row_index);
let Some(screen_row) = content_row.checked_sub(scroll_offset) else {
@@ -538,8 +558,9 @@ impl ConversationViewport {
continue;
}
for segment in &row.segments {
if segment.bytes.start >= selected_bytes.end
|| segment.bytes.end <= selected_bytes.start
if !self
.selection
.segment_is_selected(layout, row_index, &segment.columns)
{
continue;
}
+672 -45
View File
@@ -15,7 +15,9 @@ use ratatui::text::Line;
use super::*;
use crate::history_cell::AgentMarkdownCell;
use crate::history_cell::SelectionContribution;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::selection_contribution_from_display_lines;
use crate::tui::MouseScrollDirection;
#[derive(Debug)]
@@ -42,6 +44,10 @@ impl HistoryCell for TestCell {
fn is_stream_continuation(&self) -> bool {
self.is_stream_continuation
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
selection_contribution_from_display_lines(self.display_lines_for_mode(width, mode), width)
}
}
#[derive(Debug)]
@@ -66,6 +72,48 @@ impl HistoryCell for BlockStyleCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
vec!["raw source".into()]
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
selection_contribution_from_display_lines(self.display_lines_for_mode(width, mode), width)
}
}
#[derive(Debug)]
struct SourceFragmentCell {
display: &'static str,
source: &'static str,
}
impl HistoryCell for SourceFragmentCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![self.display.into()]
}
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![self.display.into()]
}
fn selection_contribution(
&self,
width: u16,
_mode: HistoryRenderMode,
) -> SelectionContribution {
match crate::history_cell::selection_contribution_from_semantic_text(
self.source.to_string(),
self.display_lines(width),
width,
/*first_row_prefix_columns*/ 0,
) {
SelectionContribution::Selectable(projection) => {
SelectionContribution::Selectable(projection.with_separator_before(""))
}
SelectionContribution::Transparent => SelectionContribution::Transparent,
}
}
fn is_stream_continuation(&self) -> bool {
true
}
}
fn cell(display: &'static str) -> Arc<dyn HistoryCell> {
@@ -77,6 +125,23 @@ fn cell(display: &'static str) -> Arc<dyn HistoryCell> {
})
}
fn live_cell(
width: u16,
display: &'static str,
is_stream_continuation: bool,
) -> ActiveCellDisplaySnapshot {
let lines = vec![HyperlinkLine::from(display)];
ActiveCellDisplaySnapshot {
selection_projection: selection_contribution_from_display_lines(
crate::terminal_hyperlinks::visible_lines(lines.clone()),
width,
)
.into_projection(),
lines,
is_stream_continuation,
}
}
fn viewport(cells: Vec<Arc<dyn HistoryCell>>) -> ConversationViewport {
ConversationViewport::new(
cells,
@@ -86,32 +151,44 @@ fn viewport(cells: Vec<Arc<dyn HistoryCell>>) -> ConversationViewport {
}
fn select_entire_projection(projection: CellSelectionProjection) -> String {
let first_bytes = projection
let (first_row, first_column) = projection
.rows()
.iter()
.find_map(|row| row.segments.first())
.expect("projection should have a first selectable grapheme")
.bytes
.clone();
let last_bytes = projection
.enumerate()
.find_map(|(row, selection_row)| {
selection_row
.segments
.first()
.map(|segment| (row, segment.columns.start))
})
.expect("projection should have a first selectable column");
let (last_row, last_column) = projection
.rows()
.iter()
.enumerate()
.rev()
.find_map(|row| row.segments.last())
.expect("projection should have a last selectable grapheme")
.bytes
.clone();
.find_map(|(row, selection_row)| {
selection_row
.segments
.last()
.map(|segment| (row, segment.columns.end.saturating_sub(/*rhs*/ 1)))
})
.expect("projection should have a last selectable column");
let layout = [SelectionCellLayout {
top: 0,
height: projection.rows().len(),
}];
let mut selection = ConversationSelection::default();
selection.start(SelectionPoint {
cell: 0,
bytes: first_bytes,
row: first_row,
column: first_column,
});
selection.update(SelectionPoint {
cell: 0,
bytes: last_bytes,
row: last_row,
column: last_column,
});
selection
.finish(/*point*/ None, &[Some(projection)])
.finish(/*point*/ None, &[Some(projection)], &layout)
.expect("dragging the full projection should select text")
}
@@ -131,7 +208,13 @@ fn renders_main_display_and_live_tail_without_pager_chrome() {
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(vec![HyperlinkLine::from("live tail")]),
|width| {
Some(vec![live_cell(
width,
"live tail",
/*is_stream_continuation*/ false,
)])
},
);
let mut terminal =
Terminal::new(TestBackend::new(/*width*/ 32, /*height*/ 6)).expect("create terminal");
@@ -272,7 +355,13 @@ fn append_keeps_live_tail_after_committed_cells() {
is_stream_continuation: false,
animation_tick: None,
}),
|_| Some(vec![HyperlinkLine::from("live tail")]),
|width| {
Some(vec![live_cell(
width,
"live tail",
/*is_stream_continuation*/ false,
)])
},
);
viewport.push_cell(cell("committed"));
@@ -291,6 +380,90 @@ fn append_keeps_live_tail_after_committed_cells() {
assert_eq!(viewport.committed_cell_count(), 1);
}
#[test]
fn selection_uses_stream_continuation_separator_before_live_cells() {
for (is_stream_continuation, live_row, expected) in [
(false, 2, "committed\n\nlive"),
(true, 1, "committed\nlive"),
] {
let mut viewport = viewport(vec![cell("committed")]);
viewport.sync_live_tail(
/*width*/ 24,
Some(ActiveCellRenderKey {
revision: 1,
is_stream_continuation,
animation_tick: None,
}),
|width| Some(vec![live_cell(width, "live", is_stream_continuation)]),
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 4,
);
viewport.render(area, &mut Buffer::empty(area));
assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0),));
assert!(viewport.update_selection(area, Position::new(/*x*/ 3, live_row),));
assert_eq!(
viewport.finish_selection(area, Position::new(/*x*/ 3, live_row)),
Some(expected.to_string())
);
}
}
#[test]
fn viewport_preserves_source_fragment_empty_separator() {
for (source, expected) in [(" beta", "alpha beta"), ("\nbeta", "alpha\nbeta")] {
let mut viewport = viewport(vec![
cell("alpha"),
Arc::new(SourceFragmentCell {
display: "beta",
source,
}),
]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 2,
);
viewport.render(area, &mut Buffer::empty(area));
assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0),));
assert!(viewport.update_selection(area, Position::new(/*x*/ 3, /*y*/ 1),));
assert_eq!(
viewport.finish_selection(area, Position::new(/*x*/ 3, /*y*/ 1)),
Some(expected.to_string())
);
}
}
#[test]
fn multiple_live_cells_keep_individual_selection_projections() {
let mut viewport = viewport(Vec::new());
viewport.sync_live_tail(
/*width*/ 24,
Some(ActiveCellRenderKey {
revision: 1,
is_stream_continuation: false,
animation_tick: None,
}),
|width| {
Some(vec![
live_cell(width, "tool output", /*is_stream_continuation*/ false),
live_cell(width, "hook output", /*is_stream_continuation*/ false),
])
},
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 24, /*height*/ 4,
);
viewport.render(area, &mut Buffer::empty(area));
assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0),));
assert!(viewport.update_selection(area, Position::new(/*x*/ 10, /*y*/ 2),));
assert_eq!(
viewport.finish_selection(area, Position::new(/*x*/ 10, /*y*/ 2)),
Some("tool output\n\nhook output".to_string())
);
}
#[test]
fn replacing_cells_invalidates_and_respaces_the_live_tail() {
let key = ActiveCellRenderKey {
@@ -299,13 +472,21 @@ fn replacing_cells_invalidates_and_respaces_the_live_tail() {
animation_tick: None,
};
let mut viewport = viewport(Vec::new());
viewport.sync_live_tail(/*width*/ 24, Some(key), |_| {
Some(vec![HyperlinkLine::from("live tail")])
viewport.sync_live_tail(/*width*/ 24, Some(key), |width| {
Some(vec![live_cell(
width,
"live tail",
/*is_stream_continuation*/ false,
)])
});
viewport.replace_cells(vec![cell("replacement")]);
viewport.sync_live_tail(/*width*/ 24, Some(key), |_| {
Some(vec![HyperlinkLine::from("live tail")])
viewport.sync_live_tail(/*width*/ 24, Some(key), |width| {
Some(vec![live_cell(
width,
"live tail",
/*is_stream_continuation*/ false,
)])
});
let area = Rect::new(
@@ -337,7 +518,15 @@ fn preserves_semantic_links_for_committed_and_live_content() {
is_stream_continuation: false,
animation_tick: None,
}),
|width| Some(live.display_hyperlink_lines(width)),
|width| {
Some(vec![ActiveCellDisplaySnapshot {
lines: live.display_hyperlink_lines(width),
selection_projection: live
.selection_contribution(width, HistoryRenderMode::Rich)
.into_projection(),
is_stream_continuation: live.is_stream_continuation(),
}])
},
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 28, /*height*/ 6,
@@ -475,6 +664,45 @@ LATEST
"###);
}
#[test]
fn mouse_wheel_extends_an_active_selection_into_offscreen_history() {
let mut viewport = viewport(vec![
cell("oldest"),
cell("older"),
cell("middle"),
cell("newer"),
cell("LATEST"),
]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3,
);
viewport.render(area, &mut Buffer::empty(area));
let anchor = Position::new(/*x*/ 5, /*y*/ 2);
let focus = Position::new(/*x*/ 0, /*y*/ 0);
assert!(viewport.begin_selection(area, anchor));
assert!(viewport.update_selection(area, focus));
viewport.handle_selection_mouse_scroll(area, MouseScrollDirection::Up, focus);
assert!(viewport.selection_is_active());
assert!(!viewport.is_following_bottom());
let mut highlighted = Buffer::empty(area);
viewport.render(area, &mut highlighted);
for column in 0.."middle".len() as u16 {
assert!(
highlighted[(column, 1)]
.modifier
.contains(Modifier::REVERSED),
"middle column {column} should be highlighted after scrolling"
);
}
assert_eq!(
viewport.finish_selection(area, focus),
Some("middle\n\nnewer\n\nLATEST".to_string())
);
}
#[test]
fn selection_hit_testing_accounts_for_scroll_offset_and_cell_spacing() {
let mut viewport = viewport(vec![cell("oldest"), cell("LATEST")]);
@@ -483,7 +711,8 @@ fn selection_hit_testing_accounts_for_scroll_offset_and_cell_spacing() {
);
viewport.render(area, &mut Buffer::empty(area));
assert!(!viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0)));
assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0)));
viewport.cancel_selection();
assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 1),));
assert!(viewport.update_selection(area, Position::new(/*x*/ 5, /*y*/ 1),));
@@ -502,6 +731,80 @@ fn selection_hit_testing_accounts_for_scroll_offset_and_cell_spacing() {
);
}
#[test]
fn selection_can_start_in_right_padding_and_drag_backward() {
let mut viewport = viewport(vec![cell("hello")]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 1,
);
viewport.render(area, &mut Buffer::empty(area));
assert!(viewport.begin_selection(area, Position::new(/*x*/ 19, /*y*/ 0)));
assert!(viewport.update_selection(area, Position::new(/*x*/ 1, /*y*/ 0)));
let mut highlighted = Buffer::empty(area);
viewport.render(area, &mut highlighted);
let mask = (area.x..area.right())
.map(|x| {
if highlighted[(x, area.y)]
.modifier
.contains(Modifier::REVERSED)
{
'#'
} else {
'.'
}
})
.collect::<String>();
assert_snapshot!(mask, @".####...............");
assert_eq!(
viewport.finish_selection(area, Position::new(/*x*/ 1, /*y*/ 0)),
Some("ello".to_string())
);
}
#[test]
fn selection_can_start_in_inter_cell_spacing() {
let mut viewport = viewport(vec![cell("first"), cell("second")]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3,
);
viewport.render(area, &mut Buffer::empty(area));
assert!(viewport.begin_selection(area, Position::new(/*x*/ 10, /*y*/ 1)));
assert!(viewport.update_selection(area, Position::new(/*x*/ 5, /*y*/ 2)));
assert_eq!(
viewport.finish_selection(area, Position::new(/*x*/ 5, /*y*/ 2)),
Some("second".to_string())
);
}
#[test]
fn backward_selection_can_start_below_the_last_cell() {
let mut viewport = viewport(vec![cell("hello")]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3,
);
viewport.render(area, &mut Buffer::empty(area));
assert!(viewport.begin_selection(area, Position::new(/*x*/ 19, /*y*/ 2)));
assert!(viewport.update_selection(area, Position::new(/*x*/ 0, /*y*/ 0)));
assert_eq!(
viewport.finish_selection(area, Position::new(/*x*/ 0, /*y*/ 0)),
Some("hello".to_string())
);
}
#[test]
fn press_outside_conversation_area_does_not_start_selection() {
let mut viewport = viewport(vec![cell("hello")]);
let area = Rect::new(
/*x*/ 2, /*y*/ 2, /*width*/ 20, /*height*/ 3,
);
viewport.render(area, &mut Buffer::empty(area));
assert!(!viewport.begin_selection(area, Position::new(/*x*/ 1, /*y*/ 2)));
}
#[test]
fn release_beyond_the_last_drag_lazily_projects_the_full_cell_span() {
let mut viewport = viewport(vec![cell("first"), cell("second"), cell("third")]);
@@ -519,25 +822,77 @@ fn release_beyond_the_last_drag_lazily_projects_the_full_cell_span() {
}
#[test]
fn replacing_cells_cancels_an_active_selection() {
fn replacing_cells_is_deferred_until_active_selection_finishes() {
let mut viewport = viewport(vec![cell("old")]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 2,
);
viewport.render(area, &mut Buffer::empty(area));
assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0),));
assert!(viewport.update_selection(area, Position::new(/*x*/ 2, /*y*/ 0),));
viewport.replace_cells(vec![cell("replacement")]);
assert!(!viewport.selection_is_active());
assert!(viewport.selection_is_active());
assert_eq!(
viewport.finish_selection(area, Position::new(/*x*/ 3, /*y*/ 0)),
None
viewport.finish_selection(area, Position::new(/*x*/ 2, /*y*/ 0)),
Some("old".to_string())
);
let mut updated = Buffer::empty(area);
viewport.render(area, &mut updated);
assert!(buffer_text(&updated, area).starts_with("replacement"));
}
#[test]
fn animation_only_live_tail_update_preserves_an_active_selection() {
fn width_change_safely_applies_a_deferred_shrinking_replacement() {
let mut viewport = viewport(vec![cell("first"), cell("second")]);
let wide = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3,
);
viewport.render(wide, &mut Buffer::empty(wide));
assert!(viewport.begin_selection(wide, Position::new(/*x*/ 0, /*y*/ 0),));
assert!(viewport.update_selection(wide, Position::new(/*x*/ 4, /*y*/ 0),));
viewport.replace_cells(vec![cell("new")]);
let narrow = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 10, /*height*/ 3,
);
viewport.render(narrow, &mut Buffer::empty(narrow));
assert!(!viewport.selection_is_active());
assert_eq!(viewport.committed_cell_count(), 1);
let mut updated = Buffer::empty(narrow);
viewport.render(narrow, &mut updated);
assert!(buffer_text(&updated, narrow).starts_with("new"));
}
#[test]
fn committed_stream_chunks_are_deferred_until_active_selection_finishes() {
let mut viewport = viewport(vec![cell("stable")]);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 3,
);
viewport.render(area, &mut Buffer::empty(area));
assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0),));
assert!(viewport.update_selection(area, Position::new(/*x*/ 5, /*y*/ 0),));
viewport.push_cell(cell("new stream chunk"));
let mut frozen = Buffer::empty(area);
viewport.render(area, &mut frozen);
assert!(viewport.selection_is_active());
assert!(!buffer_text(&frozen, area).contains("new stream chunk"));
assert_eq!(
viewport.finish_selection(area, Position::new(/*x*/ 5, /*y*/ 0)),
Some("stable".to_string())
);
let mut updated = Buffer::empty(area);
viewport.render(area, &mut updated);
assert!(buffer_text(&updated, area).contains("new stream chunk"));
}
#[test]
fn live_tail_updates_are_frozen_while_selecting_live_text() {
let mut viewport = viewport(vec![cell("committed")]);
viewport.sync_live_tail(
/*width*/ 20,
@@ -546,13 +901,17 @@ fn animation_only_live_tail_update_preserves_an_active_selection() {
is_stream_continuation: false,
animation_tick: Some(1),
}),
|_| Some(vec![HyperlinkLine::from("live one")]),
|width| {
Some(vec![live_cell(
width, "live one", /*is_stream_continuation*/ false,
)])
},
);
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 20, /*height*/ 4,
);
viewport.render(area, &mut Buffer::empty(area));
assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 0),));
assert!(viewport.begin_selection(area, Position::new(/*x*/ 0, /*y*/ 2),));
viewport.sync_live_tail(
/*width*/ 20,
@@ -561,19 +920,39 @@ fn animation_only_live_tail_update_preserves_an_active_selection() {
is_stream_continuation: false,
animation_tick: Some(2),
}),
|_| {
Some(vec![HyperlinkLine::from(
|width| {
Some(vec![live_cell(
width,
"live two now wraps across several terminal rows",
/*is_stream_continuation*/ false,
)])
},
);
viewport.render(area, &mut Buffer::empty(area));
let mut frozen = Buffer::empty(area);
viewport.render(area, &mut frozen);
assert!(viewport.selection_is_active());
assert!(viewport.update_selection(area, Position::new(/*x*/ 8, /*y*/ 0),));
assert!(buffer_text(&frozen, area).contains("live one"));
assert!(!buffer_text(&frozen, area).contains("live two"));
assert!(viewport.update_selection(area, Position::new(/*x*/ 7, /*y*/ 2),));
let mut highlighted = Buffer::empty(area);
viewport.render(area, &mut highlighted);
let mask = (area.x..area.right())
.map(|x| {
if highlighted[(x, area.y.saturating_add(/*rhs*/ 2))]
.modifier
.contains(Modifier::REVERSED)
{
'#'
} else {
'.'
}
})
.collect::<String>();
assert_snapshot!(mask, @"########............");
assert_eq!(
viewport.finish_selection(area, Position::new(/*x*/ 8, /*y*/ 0)),
Some("committed".to_string())
viewport.finish_selection(area, Position::new(/*x*/ 7, /*y*/ 2)),
Some("live one".to_string())
);
}
@@ -589,7 +968,8 @@ fn user_selection_is_source_exact_at_narrow_and_wide_widths() {
for width in [18, 80] {
let projection = cell
.selection_projection(width, HistoryRenderMode::Rich)
.selection_contribution(width, HistoryRenderMode::Rich)
.into_projection()
.expect("user message should be selectable");
assert_eq!(select_entire_projection(projection), source);
}
@@ -604,7 +984,8 @@ fn agent_markdown_selection_is_semantic_and_width_invariant() {
for width in [18, 80] {
let projection = cell
.selection_projection(width, HistoryRenderMode::Rich)
.selection_contribution(width, HistoryRenderMode::Rich)
.into_projection()
.expect("agent Markdown should be selectable");
assert_eq!(select_entire_projection(projection), expected);
}
@@ -645,23 +1026,269 @@ fn agent_markdown_partial_selection_uses_screen_coordinates_and_semantic_link_te
}
#[test]
fn agent_tables_require_raw_mode_until_layout_exposes_source_ranges() {
fn agent_tables_copy_logical_cells_without_layout_chrome() {
let markdown = "| Name | Description |\n| --- | --- |\n| alpha | a value that wraps over several words |\n| beta | short |";
let cell = AgentMarkdownCell::new(markdown.to_string(), std::path::Path::new("/tmp"));
let expected = "Name\tDescription\nalpha\ta value that wraps over several words\nbeta\tshort";
for width in [12, 80] {
assert!(
cell.selection_projection(width, HistoryRenderMode::Rich)
.is_none(),
"rich table layout cannot yet expose exact source ranges at width {width}",
for width in [12, 16, 20, 24, 32, 40, 80] {
let projection = cell
.selection_contribution(width, HistoryRenderMode::Rich)
.into_projection()
.expect("rich table selection should expose a projection");
assert_eq!(projection.text(), expected);
assert_eq!(
select_entire_projection(projection),
expected,
"rich table selection should stay semantic at width {width}",
);
}
let projection = cell
.selection_projection(/*width*/ 24, HistoryRenderMode::Raw)
.selection_contribution(/*width*/ 24, HistoryRenderMode::Raw)
.into_projection()
.expect("raw Markdown should remain selectable");
assert_eq!(select_entire_projection(projection), markdown);
}
#[test]
fn agent_table_projection_resumes_after_table_and_preceding_overlong_line() {
let markdown = "before\n\n```text\nthis-is-an-intentionally-overlong-code-line\n```\n\n| Name | Value |\n| --- | --- |\n| alpha | a value that wraps |\n\nafter";
let cell = AgentMarkdownCell::new(markdown.to_string(), std::path::Path::new("/tmp"));
let expected = crate::markdown_render::render_markdown_selection_text(
markdown,
Some(std::path::Path::new("/tmp")),
);
let projection = cell
.selection_contribution(/*width*/ 16, HistoryRenderMode::Rich)
.into_projection()
.expect("mixed Markdown table output should be selectable");
assert_eq!(select_entire_projection(projection), expected);
}
#[test]
fn narrow_wrapped_table_value_selection_omits_wrap_newlines_and_padding() {
let markdown =
"| Name | Description |\n| --- | --- |\n| alpha | a value that wraps over several words |";
let cell = AgentMarkdownCell::new(markdown.to_string(), std::path::Path::new("/tmp"));
let projection = cell
.selection_contribution(/*width*/ 16, HistoryRenderMode::Rich)
.into_projection()
.expect("wrapped table should expose source-backed rows");
let expected = "value that wraps over several words";
let start_byte = projection
.text()
.find(expected)
.expect("canonical table text should contain the wrapped value");
let end_byte = start_byte.saturating_add(expected.len());
let start = projection
.rows()
.iter()
.enumerate()
.find_map(|(row, selection_row)| {
selection_row
.segments
.iter()
.find(|segment| segment.bytes.start == start_byte)
.map(|segment| SelectionPoint {
row,
column: segment.columns.start,
})
})
.expect("wrapped value start should map to a screen column");
let end = projection
.rows()
.iter()
.enumerate()
.find_map(|(row, selection_row)| {
selection_row
.segments
.iter()
.find(|segment| segment.bytes.end == end_byte)
.map(|segment| SelectionPoint {
row,
column: segment.columns.end.saturating_sub(/*rhs*/ 1),
})
})
.expect("wrapped value end should map to a screen column");
assert!(
start.row < end.row,
"fixture should wrap across screen rows"
);
let layout = [SelectionCellLayout {
top: 0,
height: projection.rows().len(),
}];
let mut selection = ConversationSelection::default();
selection.start(start);
assert!(selection.update(end));
assert_eq!(
selection.finish(Some(end), &[Some(projection)], &layout),
Some(expected.to_string())
);
}
#[test]
fn narrow_table_adjacent_field_selection_is_stable_in_both_directions() {
let markdown =
"| Name | Description |\n| --- | --- |\n| alpha | a value that wraps over several words |";
let cell = AgentMarkdownCell::new(markdown.to_string(), std::path::Path::new("/tmp"));
let projection = cell
.selection_contribution(/*width*/ 12, HistoryRenderMode::Rich)
.into_projection()
.expect("narrow table should expose source-backed rows");
let expected = "Description\nalpha\ta value that wraps over several words";
let start_byte = projection
.text()
.find("alpha")
.expect("canonical table text should contain the first body field");
let value = "a value that wraps over several words";
let end_byte = projection
.text()
.find(value)
.expect("canonical table text should contain the second body field")
.saturating_add(value.len());
let point_for_byte = |byte: usize, use_end: bool| {
projection
.rows()
.iter()
.enumerate()
.find_map(|(row, selection_row)| {
selection_row.segments.iter().find_map(|segment| {
let matches = if use_end {
segment.bytes.end == byte
} else {
segment.bytes.start == byte
};
matches.then(|| SelectionPoint {
row,
column: if use_end {
segment.columns.end.saturating_sub(/*rhs*/ 1)
} else {
segment.columns.start
},
})
})
})
.expect("body field endpoint should map to a screen column")
};
let start = point_for_byte(start_byte, /*use_end*/ false);
let end = point_for_byte(end_byte, /*use_end*/ true);
assert!(
start.row < end.row,
"fixture should use stacked record fields"
);
let layout = [SelectionCellLayout {
top: 0,
height: projection.rows().len(),
}];
for (anchor, focus) in [(start, end), (end, start)] {
let mut selection = ConversationSelection::default();
selection.start(anchor);
assert!(selection.update(focus));
assert_eq!(
selection.finish(Some(focus), &[Some(projection.clone())], &layout),
Some(expected.to_string())
);
}
let label = "Description";
let label_start = projection
.text()
.find(label)
.expect("visible record label should be in canonical text");
let label_end = label_start.saturating_add(label.len());
let label_start = point_for_byte(label_start, /*use_end*/ false);
let label_end = point_for_byte(label_end, /*use_end*/ true);
let mut selection = ConversationSelection::default();
selection.start(label_start);
assert!(selection.update(label_end));
assert_eq!(
selection.finish(Some(label_end), &[Some(projection.clone())], &layout,),
Some(label.to_string())
);
}
#[test]
fn wrapped_grid_continuations_remain_selectable_in_visual_order() {
let markdown =
"| Left | Right |\n| --- | --- |\n| alpha beta gamma delta | one two three four |";
let cell = AgentMarkdownCell::new(markdown.to_string(), std::path::Path::new("/tmp"));
let width = 28;
assert!(
cell.display_lines(width)
.iter()
.any(|line| line.spans.iter().any(|span| span.content.contains('━'))),
"fixture should use the aligned grid presentation"
);
let projection = cell
.selection_contribution(width, HistoryRenderMode::Rich)
.into_projection()
.expect("wrapped grid should expose source-backed rows");
let mapped_ranges = projection
.rows()
.iter()
.flat_map(|row| &row.segments)
.map(|segment| segment.bytes.clone())
.collect::<Vec<_>>();
for (start, character) in projection.text().char_indices() {
if character.is_whitespace() {
continue;
}
let range = start..start.saturating_add(character.len_utf8());
assert!(
mapped_ranges.contains(&range),
"visible semantic character {character:?} at {range:?} had no grid segment"
);
}
let continuation = "gamma delta";
let start_byte = projection
.text()
.find(continuation)
.expect("first-column continuation should remain in canonical text");
let end_byte = start_byte.saturating_add(continuation.len());
let point_for_byte = |byte: usize, use_end: bool| {
projection
.rows()
.iter()
.enumerate()
.find_map(|(row, selection_row)| {
selection_row.segments.iter().find_map(|segment| {
(if use_end {
segment.bytes.end == byte
} else {
segment.bytes.start == byte
})
.then(|| SelectionPoint {
row,
column: if use_end {
segment.columns.end.saturating_sub(/*rhs*/ 1)
} else {
segment.columns.start
},
})
})
})
.expect("grid continuation endpoint should map to a screen column")
};
let start = point_for_byte(start_byte, /*use_end*/ false);
let end = point_for_byte(end_byte, /*use_end*/ true);
let layout = [SelectionCellLayout {
top: 0,
height: projection.rows().len(),
}];
let mut selection = ConversationSelection::default();
selection.start(start);
assert!(selection.update(end));
assert_eq!(
selection.finish(Some(end), &[Some(projection)], &layout),
Some("gamma delta\nthree four".to_string())
);
}
#[test]
fn selected_user_prompt_highlights_source_glyphs_without_chrome_or_padding() {
let width = 18;
+1
View File
@@ -1,5 +1,6 @@
mod model;
mod render;
mod selection;
pub(crate) use model::CommandOutput;
#[cfg(test)]
+335 -66
View File
@@ -3,13 +3,18 @@ use std::time::Instant;
use super::model::CommandOutput;
use super::model::ExecCall;
use super::model::ExecCell;
use super::selection::ExecDisplay;
use super::selection::ExecDisplayLine;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
use crate::history_cell::SelectionContribution;
use crate::history_cell::plain_lines;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
use crate::render::highlight::highlight_bash_to_lines;
#[cfg(test)]
use crate::render::line_utils::prefix_lines;
use crate::render::line_utils::push_owned_lines;
use crate::wrapping::RtOptions;
@@ -100,6 +105,12 @@ pub(crate) struct OutputLines {
pub(crate) omitted: Option<usize>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum MiddleTruncationLine {
Keep(usize),
Ellipsis(usize),
}
pub(crate) fn output_lines(
output: Option<&CommandOutput>,
params: OutputLinesParams,
@@ -248,6 +259,21 @@ impl HistoryCell for ExecCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.transcript_lines(u16::MAX))
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
crate::history_cell::selection_contribution_from_display_lines(
self.raw_lines(),
width,
)
}
HistoryRenderMode::Rich if self.is_exploring_cell() => {
self.exploring_display(width).selection_contribution(width)
}
HistoryRenderMode::Rich => self.command_display(width).selection_contribution(width),
}
}
}
impl ExecCell {
@@ -260,23 +286,35 @@ impl ExecCell {
}
fn exploring_display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
out.push(Line::from(vec![
self.exploring_display(width).into_lines()
}
fn exploring_display(&self, width: u16) -> ExecDisplay {
let status = if self.is_active() {
"Exploring"
} else {
"Explored"
};
let header = Line::from(vec![
if self.is_active() {
activity_marker(self.active_start_time(), self.animations_enabled())
} else {
"•".dim()
},
" ".into(),
if self.is_active() {
"Exploring".bold()
} else {
"Explored".bold()
},
]));
status.bold(),
]);
let mut rows = vec![
ExecDisplayLine::generated(Line::default(), String::new()).with_header(
header,
/*presentation_prefix_columns*/ 2,
status.to_string(),
),
];
let mut calls = self.calls.clone();
let mut out_indented = Vec::new();
let mut detail_rows = Vec::new();
let mut next_source_id = 0usize;
while !calls.is_empty() {
let mut call = calls.remove(0);
if call
@@ -347,22 +385,42 @@ impl ExecCell {
for (title, line) in call_lines {
let line = Line::from(line);
let initial_indent = Line::from(vec![title.cyan(), " ".into()]);
let subsequent_indent = " ".repeat(initial_indent.width()).into();
let continuation_prefix = " ".repeat(initial_indent.width());
let subsequent_indent = Line::from(continuation_prefix.clone());
let wrapped = adaptive_wrap_line(
&line,
RtOptions::new(width as usize)
.initial_indent(initial_indent)
.initial_indent(initial_indent.clone())
.subsequent_indent(subsequent_indent),
);
push_owned_lines(&wrapped, &mut out_indented);
let mut wrapped_owned = Vec::new();
push_owned_lines(&wrapped, &mut wrapped_owned);
let mut source_line = initial_indent;
source_line.extend(line);
detail_rows.extend(
ExecDisplayLine::from_wrapped_source_with_continuation_prefix(
next_source_id,
&source_line,
wrapped_owned,
&continuation_prefix,
),
);
next_source_id = next_source_id.saturating_add(1);
}
}
out.extend(prefix_lines(out_indented, " └ ".dim(), " ".into()));
out
rows.extend(detail_rows.into_iter().enumerate().map(|(index, line)| {
let prefix = if index == 0 { " └ " } else { " " };
line.with_prefix(Line::from(prefix.dim()))
}));
ExecDisplay::new(rows)
}
fn command_display_lines(&self, width: u16) -> Vec<Line<'static>> {
self.command_display(width).into_lines()
}
fn command_display(&self, width: u16) -> ExecDisplay {
let [call] = &self.calls.as_slice() else {
panic!("Expected exactly one call in a command display cell");
};
@@ -384,11 +442,18 @@ impl ExecCell {
"Ran"
};
let mut header_line = if is_interaction {
let header_line = if is_interaction {
Line::from(vec![bullet.clone(), " ".into()])
} else {
Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()])
};
let presentation_prefix_columns =
u16::try_from(bullet.width().saturating_add(1)).unwrap_or(u16::MAX);
let semantic_header = if is_interaction {
String::new()
} else {
format!("{title} ")
};
let header_prefix_width = header_line.width();
let cmd_display = if call.is_unified_exec_interaction() {
@@ -402,7 +467,9 @@ impl ExecCell {
let continuation_opts =
RtOptions::new(continuation_wrap_width).word_splitter(WordSplitter::NoHyphenation);
let mut continuation_lines: Vec<Line<'static>> = Vec::new();
let mut continuation_lines: Vec<ExecDisplayLine> = Vec::new();
let mut next_source_id = 0usize;
let header_row;
if let Some((first, rest)) = highlighted_lines.split_first() {
let available_first_width = (width as usize).saturating_sub(header_prefix_width).max(1);
@@ -411,32 +478,57 @@ impl ExecCell {
let mut first_wrapped: Vec<Line<'static>> = Vec::new();
push_owned_lines(&adaptive_wrap_line(first, first_opts), &mut first_wrapped);
let mut first_wrapped_iter = first_wrapped.into_iter();
if let Some(first_segment) = first_wrapped_iter.next() {
header_line.extend(first_segment);
}
continuation_lines.extend(first_wrapped_iter);
let mut first_rows =
ExecDisplayLine::from_wrapped_source(next_source_id, first, first_wrapped)
.into_iter();
next_source_id = next_source_id.saturating_add(1);
header_row = first_rows
.next()
.unwrap_or_else(|| ExecDisplayLine::generated(Line::default(), String::new()))
.with_header(header_line, presentation_prefix_columns, semantic_header);
continuation_lines.extend(first_rows);
for line in rest {
let mut wrapped = Vec::new();
push_owned_lines(
&adaptive_wrap_line(line, continuation_opts.clone()),
&mut continuation_lines,
&mut wrapped,
);
continuation_lines.extend(ExecDisplayLine::from_wrapped_source(
next_source_id,
line,
wrapped,
));
next_source_id = next_source_id.saturating_add(1);
}
} else {
header_row = ExecDisplayLine::generated(Line::default(), String::new()).with_header(
header_line,
presentation_prefix_columns,
semantic_header,
);
}
let mut lines: Vec<Line<'static>> = vec![header_line];
let mut rows = vec![header_row];
let continuation_lines = Self::limit_lines_from_start(
let continuation_lines = Self::limit_exec_lines_from_start(
&continuation_lines,
layout.command_continuation_max_lines,
);
if !continuation_lines.is_empty() {
lines.extend(prefix_lines(
continuation_lines,
Span::from(layout.command_continuation.initial_prefix).dim(),
Span::from(layout.command_continuation.subsequent_prefix).dim(),
));
rows.extend(
continuation_lines
.into_iter()
.enumerate()
.map(|(index, line)| {
let prefix = if index == 0 {
layout.command_continuation.initial_prefix
} else {
layout.command_continuation.subsequent_prefix
};
line.with_prefix(Line::from(Span::from(prefix).dim()))
}),
);
}
if let Some(output) = call.output.as_ref() {
@@ -462,33 +554,48 @@ impl ExecCell {
if raw_output.lines.is_empty() {
if !call.is_unified_exec_interaction() {
lines.extend(prefix_lines(
vec![Line::from("(no output)".dim())],
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
));
rows.push(
ExecDisplayLine::generated(
Line::from("(no output)".dim()),
"(no output)".to_string(),
)
.with_prefix(Line::from(
Span::from(layout.output_block.initial_prefix).dim(),
)),
);
}
} else {
// Wrap first so that truncation is applied to on-screen lines
// rather than logical lines. This ensures that a small number
// of very long lines cannot flood the viewport.
let mut wrapped_output: Vec<Line<'static>> = Vec::new();
let mut wrapped_output: Vec<ExecDisplayLine> = Vec::new();
let output_wrap_width = layout.output_block.wrap_width(width);
let output_opts =
RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation);
for line in &raw_output.lines {
push_owned_lines(
&adaptive_wrap_line(line, output_opts.clone()),
&mut wrapped_output,
);
let mut wrapped = Vec::new();
push_owned_lines(&adaptive_wrap_line(line, output_opts.clone()), &mut wrapped);
wrapped_output.extend(ExecDisplayLine::from_wrapped_source(
next_source_id,
line,
wrapped,
));
next_source_id = next_source_id.saturating_add(1);
}
let prefixed_output = prefix_lines(
wrapped_output,
Span::from(layout.output_block.initial_prefix).dim(),
Span::from(layout.output_block.subsequent_prefix),
);
let trimmed_output = Self::truncate_lines_middle(
let prefixed_output = wrapped_output
.into_iter()
.enumerate()
.map(|(index, line)| {
let prefix = if index == 0 {
layout.output_block.initial_prefix
} else {
layout.output_block.subsequent_prefix
};
line.with_prefix(Line::from(Span::from(prefix).dim()))
})
.collect::<Vec<_>>();
let trimmed_output = Self::truncate_exec_lines_middle(
&prefixed_output,
display_limit,
width,
@@ -499,14 +606,36 @@ impl ExecCell {
);
if !trimmed_output.is_empty() {
lines.extend(trimmed_output);
rows.extend(trimmed_output);
}
}
}
lines
ExecDisplay::new(rows)
}
fn limit_exec_lines_from_start(lines: &[ExecDisplayLine], keep: usize) -> Vec<ExecDisplayLine> {
if lines.len() <= keep {
return lines.to_vec();
}
if keep == 0 {
let text = Self::ellipsis_text(lines.len());
return vec![ExecDisplayLine::generated(
Line::from(text.clone().dim()),
text,
)];
}
let mut out = lines[..keep].to_vec();
let text = Self::ellipsis_text(lines.len() - keep);
out.push(ExecDisplayLine::generated(
Line::from(text.clone().dim()),
text,
));
out
}
#[cfg(test)]
fn limit_lines_from_start(lines: &[Line<'static>], keep: usize) -> Vec<Line<'static>> {
if lines.len() <= keep {
return lines.to_vec();
@@ -536,6 +665,7 @@ impl ExecCell {
/// widths. `omitted_hint` carries forward any previously reported
/// omitted count (from upstream truncation); `ellipsis_prefix`
/// prepends the output gutter prefix to the ellipsis line.
#[cfg(test)]
fn truncate_lines_middle(
lines: &[Line<'static>],
max_rows: usize,
@@ -543,6 +673,64 @@ impl ExecCell {
omitted_hint: Option<usize>,
ellipsis_prefix: Option<Line<'static>>,
) -> Vec<Line<'static>> {
Self::middle_truncation_plan(
lines,
max_rows,
width,
omitted_hint,
ellipsis_prefix.as_ref(),
)
.into_iter()
.map(|selection| match selection {
MiddleTruncationLine::Keep(index) => lines[index].clone(),
MiddleTruncationLine::Ellipsis(omitted) => {
Self::output_ellipsis_line_with_prefix(omitted, ellipsis_prefix.as_ref())
}
})
.collect()
}
fn truncate_exec_lines_middle(
lines: &[ExecDisplayLine],
max_rows: usize,
width: u16,
omitted_hint: Option<usize>,
ellipsis_prefix: Option<Line<'static>>,
) -> Vec<ExecDisplayLine> {
let display_lines = lines
.iter()
.map(|line| line.line().clone())
.collect::<Vec<_>>();
Self::middle_truncation_plan(
&display_lines,
max_rows,
width,
omitted_hint,
ellipsis_prefix.as_ref(),
)
.into_iter()
.map(|selection| match selection {
MiddleTruncationLine::Keep(index) => lines[index].clone(),
MiddleTruncationLine::Ellipsis(omitted) => {
let text = Self::output_ellipsis_text(omitted);
let line = ExecDisplayLine::generated(Self::output_ellipsis_line(omitted), text);
if let Some(prefix) = ellipsis_prefix.clone() {
line.with_prefix(prefix)
} else {
line
}
}
})
.collect()
}
fn middle_truncation_plan(
lines: &[Line<'static>],
max_rows: usize,
width: u16,
omitted_hint: Option<usize>,
ellipsis_prefix: Option<&Line<'static>>,
) -> Vec<MiddleTruncationLine> {
let width = width.max(1);
if max_rows == 0 {
return Vec::new();
@@ -566,7 +754,7 @@ impl ExecCell {
.collect();
let total_rows: usize = line_rows.iter().sum();
if total_rows <= max_rows {
return lines.to_vec();
return (0..lines.len()).map(MiddleTruncationLine::Keep).collect();
}
// Reserve space for the transcript hint itself so the returned output
// still respects the row budget on narrow terminals.
@@ -575,18 +763,14 @@ impl ExecCell {
.len()
.saturating_sub(usize::from(omitted_hint.is_some()));
let ellipsis_rows =
Self::output_ellipsis_row_count(estimated_omitted, width, ellipsis_prefix.as_ref());
Self::output_ellipsis_row_count(estimated_omitted, width, ellipsis_prefix);
if ellipsis_rows >= max_rows {
return vec![Self::output_ellipsis_line_with_prefix(
estimated_omitted,
ellipsis_prefix.as_ref(),
)];
return vec![MiddleTruncationLine::Ellipsis(estimated_omitted)];
}
let available_rows = max_rows - ellipsis_rows;
let head_budget = available_rows / 2;
let tail_budget = available_rows - head_budget;
let mut head_lines: Vec<Line<'static>> = Vec::new();
let mut head_rows = 0usize;
let mut head_end = 0usize;
while head_end < lines.len() {
@@ -595,11 +779,9 @@ impl ExecCell {
break;
}
head_rows += line_row_count;
head_lines.push(lines[head_end].clone());
head_end += 1;
}
let mut tail_lines_reversed: Vec<Line<'static>> = Vec::new();
let mut tail_rows = 0usize;
let mut tail_start = lines.len();
while tail_start > head_end {
@@ -609,28 +791,30 @@ impl ExecCell {
break;
}
tail_rows += line_row_count;
tail_lines_reversed.push(lines[idx].clone());
tail_start -= 1;
}
let mut out = head_lines;
let base = omitted_hint.unwrap_or(0);
let additional = lines
.len()
.saturating_sub(out.len() + tail_lines_reversed.len())
.saturating_sub(head_end + lines.len().saturating_sub(tail_start))
.saturating_sub(usize::from(omitted_hint.is_some()));
out.push(Self::output_ellipsis_line_with_prefix(
base + additional,
ellipsis_prefix.as_ref(),
));
out.extend(tail_lines_reversed.into_iter().rev());
let mut out = (0..head_end)
.map(MiddleTruncationLine::Keep)
.collect::<Vec<_>>();
out.push(MiddleTruncationLine::Ellipsis(base + additional));
out.extend((tail_start..lines.len()).map(MiddleTruncationLine::Keep));
out
}
#[cfg(test)]
fn ellipsis_line(omitted: usize) -> Line<'static> {
Line::from(vec![format!("… +{omitted} lines").dim()])
Line::from(vec![Self::ellipsis_text(omitted).dim()])
}
fn ellipsis_text(omitted: usize) -> String {
format!("… +{omitted} lines")
}
fn output_ellipsis_row_count(
@@ -723,6 +907,14 @@ mod tests {
.collect::<String>()
}
fn rich_selection_text(cell: &ExecCell, width: u16) -> String {
cell.selection_contribution(width, HistoryRenderMode::Rich)
.into_projection()
.expect("exec display should be selectable")
.text()
.to_string()
}
#[test]
fn user_shell_output_is_limited_by_screen_lines() {
let long_url_like = format!(
@@ -1092,4 +1284,81 @@ mod tests {
"expected transcript height to account for wrapped URL-like rows, logical_height={logical_height}, wrapped_height={wrapped_height}"
);
}
#[test]
fn rich_selection_restores_spaces_removed_at_soft_wraps() {
let call = ExecCall {
call_id: "call-id".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo words".into()],
parsed: Vec::new(),
output: Some(CommandOutput {
exit_code: 0,
formatted_output: String::new(),
aggregated_output: "alpha beta gamma delta epsilon".to_string(),
}),
source: ExecCommandSource::Agent,
start_time: None,
duration: None,
interaction_input: None,
};
let cell = ExecCell::new(call, /*animations_enabled*/ false);
assert_eq!(
rich_selection_text(&cell, /*width*/ 16),
"Ran echo words\nalpha beta gamma delta epsilon"
);
}
#[test]
fn rich_selection_excludes_output_hidden_by_middle_truncation() {
let output = (1..=20)
.map(|line| format!("visible-line-{line:02}"))
.join("\n");
let call = ExecCall {
call_id: "call-id".to_string(),
command: vec!["bash".into(), "-lc".into(), "echo many".into()],
parsed: Vec::new(),
output: Some(CommandOutput {
exit_code: 0,
formatted_output: String::new(),
aggregated_output: output,
}),
source: ExecCommandSource::Agent,
start_time: None,
duration: None,
interaction_input: None,
};
let cell = ExecCell::new(call, /*animations_enabled*/ false);
let selected = rich_selection_text(&cell, /*width*/ 40);
assert!(selected.contains("visible-line-01"));
assert!(selected.contains("visible-line-20"));
assert!(selected.contains("ctrl + t to view transcript"));
assert!(!selected.contains("visible-line-08"));
}
#[test]
fn exploring_selection_restores_wrapped_query_spacing() {
let query = "alpha beta gamma delta epsilon";
let call = ExecCall {
call_id: "call-id".to_string(),
command: vec!["rg".into(), query.into()],
parsed: vec![ParsedCommand::Search {
cmd: format!("rg {query}"),
query: Some(query.to_string()),
path: None,
}],
output: Some(CommandOutput::default()),
source: ExecCommandSource::Agent,
start_time: None,
duration: None,
interaction_input: None,
};
let cell = ExecCell::new(call, /*animations_enabled*/ false);
assert_eq!(
rich_selection_text(&cell, /*width*/ 18),
"Explored\nSearch alpha beta gamma delta epsilon"
);
}
}
+217
View File
@@ -0,0 +1,217 @@
//! Source-backed selection metadata for rendered command cells.
//!
//! Command output is wrapped and sometimes truncated before it reaches the retained viewport.
//! These types keep each visible fragment tied to its logical source line so copying can restore
//! authored whitespace skipped at soft-wrap boundaries without including hidden truncation.
use std::ops::Range;
use std::sync::Arc;
use ratatui::text::Line;
use crate::history_cell::SelectionContribution;
use crate::history_cell::selection_contribution_from_semantic_rows;
use crate::history_cell::selection_text_from_lines;
#[derive(Clone, Debug)]
enum ExecSelectionContent {
Source {
source_id: usize,
wrap_index: usize,
source: Arc<str>,
visible_bytes: Range<usize>,
semantic_leading: String,
},
Generated(String),
}
/// One logical display line and the semantic content it contributes to selection.
#[derive(Clone, Debug)]
pub(super) struct ExecDisplayLine {
line: Line<'static>,
content: ExecSelectionContent,
prefix_columns: u16,
}
impl ExecDisplayLine {
pub(super) fn generated(line: Line<'static>, text: String) -> Self {
Self {
line,
content: ExecSelectionContent::Generated(text),
prefix_columns: 0,
}
}
/// Maps already-wrapped display fragments back to one unwrapped logical source line.
pub(super) fn from_wrapped_source(
source_id: usize,
source_line: &Line<'static>,
wrapped: Vec<Line<'static>>,
) -> Vec<Self> {
Self::from_wrapped_source_with_continuation_prefix(source_id, source_line, wrapped, "")
}
/// Maps wrapped fragments that use a presentation-only continuation indent.
pub(super) fn from_wrapped_source_with_continuation_prefix(
source_id: usize,
source_line: &Line<'static>,
wrapped: Vec<Line<'static>>,
continuation_prefix: &str,
) -> Vec<Self> {
let source = selection_text_from_lines(std::slice::from_ref(source_line));
let source: Arc<str> = Arc::from(source);
let mut cursor = 0usize;
wrapped
.into_iter()
.enumerate()
.map(|(wrap_index, line)| {
let rendered = selection_text_from_lines(std::slice::from_ref(&line));
let fragment = if wrap_index == 0 {
rendered.as_str()
} else {
rendered
.strip_prefix(continuation_prefix)
.unwrap_or(rendered.as_str())
};
let start = source[cursor..]
.find(fragment)
.map(|offset| cursor.saturating_add(offset))
.unwrap_or(cursor);
let end = start.saturating_add(fragment.len()).min(source.len());
cursor = end;
Self {
line,
content: ExecSelectionContent::Source {
source_id,
wrap_index,
source: Arc::clone(&source),
visible_bytes: start..end,
semantic_leading: String::new(),
},
prefix_columns: if wrap_index == 0 {
0
} else {
u16::try_from(Line::from(continuation_prefix).width()).unwrap_or(u16::MAX)
},
}
})
.collect()
}
pub(super) fn with_prefix(mut self, mut prefix: Line<'static>) -> Self {
self.prefix_columns = self
.prefix_columns
.saturating_add(u16::try_from(prefix.width()).unwrap_or(u16::MAX));
prefix.spans.append(&mut self.line.spans);
self.line = prefix;
self
}
/// Prepends a header containing both presentation-only and semantic spans.
pub(super) fn with_header(
mut self,
mut header: Line<'static>,
presentation_prefix_columns: u16,
semantic_leading: String,
) -> Self {
self.prefix_columns = presentation_prefix_columns;
match &mut self.content {
ExecSelectionContent::Source {
semantic_leading: existing,
..
} => existing.insert_str(0, &semantic_leading),
ExecSelectionContent::Generated(text) => text.insert_str(0, &semantic_leading),
}
header.spans.append(&mut self.line.spans);
self.line = header;
self
}
pub(super) fn line(&self) -> &Line<'static> {
&self.line
}
pub(super) fn into_line(self) -> Line<'static> {
self.line
}
}
/// A rendered command cell whose clipboard text is assembled from only visible fragments.
pub(super) struct ExecDisplay {
rows: Vec<ExecDisplayLine>,
}
impl ExecDisplay {
pub(super) fn new(rows: Vec<ExecDisplayLine>) -> Self {
Self { rows }
}
pub(super) fn into_lines(self) -> Vec<Line<'static>> {
self.rows
.into_iter()
.map(ExecDisplayLine::into_line)
.collect()
}
pub(super) fn selection_contribution(&self, width: u16) -> SelectionContribution {
let mut text = String::new();
let mut previous_source: Option<(usize, usize, usize)> = None;
for row in &self.rows {
match &row.content {
ExecSelectionContent::Source {
source_id,
wrap_index,
source,
visible_bytes,
semantic_leading,
} => {
let continues_previous = previous_source.is_some_and(
|(previous_id, previous_wrap, previous_end)| {
previous_id == *source_id
&& previous_wrap.saturating_add(1) == *wrap_index
&& previous_end <= visible_bytes.start
},
);
if continues_previous {
let previous_end = previous_source
.map(|(_, _, end)| end)
.unwrap_or(visible_bytes.start);
if let Some(fragment) = source.get(previous_end..visible_bytes.end) {
text.push_str(fragment);
}
} else {
if !text.is_empty() {
text.push('\n');
}
text.push_str(semantic_leading);
if let Some(fragment) = source.get(visible_bytes.clone()) {
text.push_str(fragment);
}
}
previous_source = Some((*source_id, *wrap_index, visible_bytes.end));
}
ExecSelectionContent::Generated(fragment) => {
if !text.is_empty() {
text.push('\n');
}
text.push_str(fragment);
previous_source = None;
}
}
}
let lines = self
.rows
.iter()
.map(|row| row.line.clone())
.collect::<Vec<_>>();
let prefix_columns = self
.rows
.iter()
.map(|row| row.prefix_columns)
.collect::<Vec<_>>();
selection_contribution_from_semantic_rows(text, lines, width, &prefix_columns)
}
}
@@ -183,6 +183,34 @@ pub(crate) fn external_agent_config_migration_finished_lines(
lines
}
/// Returns clipboard text and per-line presentation-prefix widths for an import status message.
pub(crate) fn external_agent_config_migration_selection(
lines: &[Line<'static>],
) -> (String, Vec<u16>) {
let mut selection_lines = Vec::with_capacity(lines.len());
let mut prefix_columns = Vec::with_capacity(lines.len());
for line in lines {
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
let (semantic, prefix_columns_for_line) =
if let Some(semantic) = rendered.strip_prefix(" ") {
(semantic, 4)
} else if let Some(semantic) = rendered.strip_prefix("• ") {
(semantic, 2)
} else if let Some(semantic) = rendered.strip_prefix(" ") {
(semantic, 2)
} else {
(rendered.as_str(), 0)
};
selection_lines.push(semantic.to_string());
prefix_columns.push(prefix_columns_for_line);
}
(selection_lines.join("\n"), prefix_columns)
}
fn remaining_items_handoff(remaining_item_count: usize) -> Option<String> {
match remaining_item_count {
0 => None,
@@ -257,3 +257,25 @@ fn external_agent_config_migration_status_lines_use_semantic_colors() {
]
);
}
#[test]
fn external_agent_config_migration_selection_excludes_bullets_and_indentation() {
let started = external_agent_config_migration_started_lines(
&selected_items(),
/*remaining_item_count*/ 0,
);
let (started_text, started_prefixes) = external_agent_config_migration_selection(&started);
assert_eq!(started_prefixes, vec![2, 2, 2, 4, 4, 4, 4, 4]);
assert!(started_text.starts_with("Claude Code import started."));
assert!(started_text.contains("\nSettings: 1"));
assert!(!started_text.contains("\n "));
assert!(!started_text.contains('•'));
let finished = external_agent_config_migration_finished_lines(&completed_notification());
let (finished_text, finished_prefixes) = external_agent_config_migration_selection(&finished);
assert_eq!(finished_prefixes, vec![2, 2, 4, 4, 2]);
assert!(finished_text.starts_with("Claude Code import finished:"));
assert!(finished_text.contains("\nPlugins: 1 imported, 1 failed"));
assert!(!finished_text.contains("\n "));
assert!(!finished_text.contains('•'));
}
+1 -3
View File
@@ -354,7 +354,5 @@ pub fn new_guardian_timed_out_action_request(summary: String) -> Box<dyn History
/// Cyan history cell line showing the current review status.
pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
PlainHistoryCell {
lines: vec![Line::from(message.cyan())],
}
PlainHistoryCell::new(vec![Line::from(message.cyan())])
}
+111 -1
View File
@@ -1,15 +1,43 @@
//! Shared history-cell building blocks reused across transcript concerns.
use super::*;
use crate::conversation_selection::CellSelectionProjection;
use crate::conversation_selection::CellSelectionProjectionPart;
#[derive(Debug)]
pub(crate) struct PlainHistoryCell {
pub(super) lines: Vec<Line<'static>>,
selection: PlainHistoryCellSelection,
}
#[derive(Debug)]
enum PlainHistoryCellSelection {
DisplayText,
Semantic {
text: String,
first_row_prefix_columns: Vec<u16>,
},
}
impl PlainHistoryCell {
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self {
Self { lines }
Self {
lines,
selection: PlainHistoryCellSelection::DisplayText,
}
}
/// Uses semantic clipboard text instead of copying presentation chrome from rendered lines.
pub(crate) fn with_selection_text(
mut self,
text: String,
first_row_prefix_columns: Vec<u16>,
) -> Self {
self.selection = PlainHistoryCellSelection::Semantic {
text,
first_row_prefix_columns,
};
self
}
}
@@ -21,6 +49,24 @@ impl HistoryCell for PlainHistoryCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.lines.clone())
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
let lines = self.display_lines_for_mode(width, mode);
match &self.selection {
PlainHistoryCellSelection::DisplayText => {
selection_contribution_from_display_lines(lines, width)
}
PlainHistoryCellSelection::Semantic {
text,
first_row_prefix_columns,
} => selection_contribution_from_semantic_rows(
text.clone(),
lines,
width,
first_row_prefix_columns,
),
}
}
}
#[derive(Debug)]
@@ -50,6 +96,10 @@ impl HistoryCell for WebHyperlinkHistoryCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.lines.clone())
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
selection_contribution_from_display_lines(self.display_lines_for_mode(width, mode), width)
}
}
#[derive(Debug)]
pub(crate) struct PrefixedWrappedHistoryCell {
@@ -86,6 +136,36 @@ impl HistoryCell for PrefixedWrappedHistoryCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.text.clone().lines)
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => {
let lines = self.display_lines(width);
let initial_prefix_width =
u16::try_from(self.initial_prefix.width()).unwrap_or(u16::MAX);
let subsequent_prefix_width =
u16::try_from(self.subsequent_prefix.width()).unwrap_or(u16::MAX);
let prefix_columns = (0..lines.len())
.map(|index| {
if index == 0 {
initial_prefix_width
} else {
subsequent_prefix_width
}
})
.collect::<Vec<_>>();
selection_contribution_from_semantic_rows(
selection_text_from_lines(&self.text.lines),
lines,
width,
&prefix_columns,
)
}
}
}
}
#[derive(Debug)]
pub(crate) struct CompositeHistoryCell {
@@ -162,4 +242,34 @@ impl HistoryCell for CompositeHistoryCell {
}
out
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
let parts = self
.parts
.iter()
.filter_map(|part| {
let display_lines = part.display_lines_for_mode(width, mode);
if display_lines.is_empty() {
return None;
}
let row_count = Paragraph::new(Text::from(display_lines))
.wrap(Wrap { trim: false })
.line_count(width);
Some(match part.selection_contribution(width, mode) {
SelectionContribution::Selectable(projection) => {
CellSelectionProjectionPart::Selectable(projection)
}
SelectionContribution::Transparent => {
CellSelectionProjectionPart::Transparent { row_count }
}
})
})
.collect();
match CellSelectionProjection::compose(
parts, /*blank_rows_between*/ 1, /*text_separator*/ "\n\n",
) {
Some(projection) => SelectionContribution::Selectable(projection),
None => SelectionContribution::Transparent,
}
}
}
+102
View File
@@ -2,6 +2,23 @@
use super::*;
fn rendered_line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn selection_prefix_width(line: &Line<'_>, prefixes: &[&str]) -> u16 {
let rendered = rendered_line_text(line);
for prefix in prefixes {
if rendered.starts_with(prefix) {
return u16::try_from(UnicodeWidthStr::width(*prefix)).unwrap_or(u16::MAX);
}
}
0
}
#[derive(Debug)]
pub(crate) struct UnifiedExecInteractionCell {
command_display: Option<String>,
@@ -15,6 +32,29 @@ impl UnifiedExecInteractionCell {
stdin,
}
}
fn rich_selection_text(&self) -> String {
let waited_only = self.stdin.is_empty();
let mut header = if waited_only {
"Waited for background terminal".to_string()
} else {
"Interacted with background terminal".to_string()
};
if let Some(command) = self
.command_display
.as_ref()
.filter(|command| !command.is_empty())
{
header.push_str(" · ");
header.push_str(command);
}
let mut lines = vec![Line::from(header)];
if !waited_only {
lines.extend(raw_lines_from_source(&self.stdin));
}
selection_text_from_lines(&lines)
}
}
impl HistoryCell for UnifiedExecInteractionCell {
@@ -93,6 +133,27 @@ impl HistoryCell for UnifiedExecInteractionCell {
out.extend(raw_lines_from_source(&self.stdin));
out
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => {
let lines = self.display_lines(width);
let prefix_columns = lines
.iter()
.map(|line| selection_prefix_width(line, &[" └ ", " ", "↳ ", "• "]))
.collect::<Vec<_>>();
selection_contribution_from_semantic_rows(
self.rich_selection_text(),
lines,
width,
&prefix_columns,
)
}
}
}
}
pub(crate) fn new_unified_exec_interaction(
@@ -108,6 +169,26 @@ struct UnifiedExecProcessesCell {
}
impl UnifiedExecProcessesCell {
fn rich_selection_text(lines: &[Line<'_>]) -> String {
lines
.iter()
.enumerate()
.map(|(index, line)| {
let rendered = rendered_line_text(line);
if index == 0 {
return rendered;
}
for prefix in [" ↳ ", " ", " • "] {
if let Some(content) = rendered.strip_prefix(prefix) {
return content.to_string();
}
}
rendered
})
.collect::<Vec<_>>()
.join("\n")
}
fn new(processes: Vec<UnifiedExecProcessDetails>) -> Self {
Self { processes }
}
@@ -228,6 +309,27 @@ impl HistoryCell for UnifiedExecProcessesCell {
plain_lines(self.display_lines(u16::MAX))
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => {
let lines = self.display_lines(width);
let prefix_columns = lines
.iter()
.map(|line| selection_prefix_width(line, &[" ↳ ", " ", " • "]))
.collect::<Vec<_>>();
selection_contribution_from_semantic_rows(
Self::rich_selection_text(&lines),
lines,
width,
&prefix_columns,
)
}
}
}
fn desired_height(&self, width: u16) -> u16 {
self.display_lines(width).len() as u16
}
@@ -11,7 +11,10 @@
//! first drawn.
//! 4. Completed runs only persist when they have output or a non-success status.
use super::HistoryCell;
use super::HistoryRenderMode;
use super::SelectionContribution;
use super::plain_lines;
use super::selection_contribution_from_semantic_text;
use crate::motion::MotionMode;
use crate::motion::ReducedMotionIndicator;
use crate::motion::activity_indicator;
@@ -180,6 +183,72 @@ impl HookCell {
self.runs.iter().any(|run| run.state.is_running_visible())
}
fn selection_text(&self) -> String {
let mut blocks = Vec::new();
let mut running_group: Option<RunningHookGroup> = None;
let push_running_group = |blocks: &mut Vec<String>, group: RunningHookGroup| {
let label = hook_event_label(group.key.event_name);
let mut text = if group.count == 1 {
format!("Running {label} hook")
} else {
format!("Running {} {label} hooks", group.count)
};
if let Some(status) = group
.key
.status_message
.as_deref()
.filter(|status| !status.is_empty())
{
text.push_str(": ");
text.push_str(status);
}
blocks.push(text);
};
for run in &self.runs {
if !run.state.should_render() {
continue;
}
if let Some(key) = run.running_group_key() {
if let Some(group) = running_group.as_mut()
&& group.key == key
{
group.count += 1;
continue;
}
if let Some(group) =
running_group.replace(RunningHookGroup::new(key, run.state.start_time()))
{
push_running_group(&mut blocks, group);
}
continue;
}
if let Some(group) = running_group.take() {
push_running_group(&mut blocks, group);
}
let HookRunState::Completed { status, entries } = &run.state else {
continue;
};
let label = hook_event_label(run.event_name);
let status_text = format!("{status:?}").to_lowercase();
let mut lines = vec![format!("{label} hook ({status_text})")];
for entry in entries {
let prefix = hook_output_prefix(entry.kind);
let mut output_lines = entry.text.split('\n');
if let Some(first) = output_lines.next() {
lines.push(format!("{prefix}{first}"));
}
lines.extend(output_lines.map(str::to_string));
}
blocks.push(lines.join("\n"));
}
if let Some(group) = running_group {
push_running_group(&mut blocks, group);
}
blocks.join("\n\n")
}
/// Advances reveal/removal timers and reports whether rendering should be refreshed.
pub(crate) fn advance_time(&mut self, now: Instant) -> bool {
let old_len = self.runs.len();
@@ -348,6 +417,15 @@ impl HistoryCell for HookCell {
plain_lines(self.display_lines(u16::MAX))
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
selection_contribution_from_semantic_text(
self.selection_text(),
self.display_lines_for_mode(width, mode),
width,
/*first_row_prefix_columns*/ 0,
)
}
/// Produces a coarse cache key for transcript overlays while hook animations are active.
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled {
+99 -3
View File
@@ -14,6 +14,10 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput {
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![Line::from("tool result (image output)")]
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
selection_contribution_from_display_lines(self.display_lines_for_mode(width, mode), width)
}
}
fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str {
match status {
@@ -23,6 +27,26 @@ fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str {
McpAuthStatus::OAuth => "OAuth",
}
}
fn mcp_inventory_cell(lines: Vec<Line<'static>>) -> PlainHistoryCell {
let mut selection_lines = Vec::with_capacity(lines.len());
let mut prefix_columns = Vec::with_capacity(lines.len());
for line in &lines {
let rendered = selection_text_from_lines(std::slice::from_ref(line));
let (content, prefix) = ["🔌 ", " • ", " • ", " "]
.into_iter()
.find_map(|prefix| {
rendered
.strip_prefix(prefix)
.map(|content| (content, prefix))
})
.unwrap_or((rendered.as_str(), ""));
selection_lines.push(content.to_string());
prefix_columns.push(u16::try_from(UnicodeWidthStr::width(prefix)).unwrap_or(u16::MAX));
}
PlainHistoryCell::new(lines).with_selection_text(selection_lines.join("\n"), prefix_columns)
}
#[derive(Debug)]
pub(crate) struct McpToolCallCell {
call_id: String,
@@ -114,6 +138,50 @@ impl McpToolCallCell {
rmcp::model::RawContent::ResourceLink(link) => format!("link: {}", link.uri),
}
}
fn rich_selection_text(&self, width: u16) -> String {
let header = if self.success().is_some() {
"Called"
} else {
"Calling"
};
let invocation_line = line_to_static(&format_mcp_invocation(self.invocation.clone()));
let invocation = selection_text_from_lines(std::slice::from_ref(&invocation_line));
let reserved = UnicodeWidthStr::width("• ")
.saturating_add(UnicodeWidthStr::width(header))
.saturating_add(UnicodeWidthStr::width(" "));
let inline_invocation =
invocation_line.width() <= usize::from(width).saturating_sub(reserved);
let mut text = if inline_invocation {
format!("{header} {invocation}")
} else {
format!("{header}\n{invocation}")
};
let detail_width = usize::from(width).saturating_sub(4).max(1);
let mut details = Vec::new();
if let Some(result) = &self.result {
match result {
Ok(codex_protocol::mcp::CallToolResult { content, .. }) => {
details.extend(
content
.iter()
.map(|block| Self::render_content_block(block, detail_width)),
);
}
Err(err) => details.push(format_and_truncate_tool_result(
&format!("Error: {err}"),
TOOL_CALL_MAX_LINES,
usize::from(width),
)),
}
}
if !details.is_empty() {
text.push('\n');
text.push_str(&details.join("\n"));
}
text
}
}
impl HistoryCell for McpToolCallCell {
@@ -238,6 +306,20 @@ impl HistoryCell for McpToolCallCell {
lines
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Rich => selection_contribution_from_semantic_text(
self.rich_selection_text(width),
self.display_lines(width),
width,
/*first_row_prefix_columns*/ 2,
),
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
}
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled || self.result.is_some() {
return None;
@@ -335,7 +417,7 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell {
.style(Style::default().add_modifier(Modifier::DIM)),
];
PlainHistoryCell::new(lines)
mcp_inventory_cell(lines)
}
#[cfg(test)]
@@ -509,7 +591,7 @@ pub(crate) fn new_mcp_tools_output(
lines.push(Line::from(""));
}
PlainHistoryCell { lines }
mcp_inventory_cell(lines)
}
/// Build the `/mcp` history cell from app-server `McpServerStatus` responses.
@@ -612,7 +694,7 @@ pub(crate) fn new_mcp_tools_output_from_statuses(
lines.push(Line::from(""));
}
PlainHistoryCell { lines }
mcp_inventory_cell(lines)
}
/// A transient history cell that shows an animated spinner while the MCP
/// inventory RPC is in flight.
@@ -658,6 +740,20 @@ impl HistoryCell for McpInventoryLoadingCell {
vec![Line::from("Loading MCP inventory...")]
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Rich => selection_contribution_from_semantic_text(
"Loading MCP inventory…".to_string(),
self.display_lines(width),
width,
/*first_row_prefix_columns*/ 2,
),
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
}
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled {
return None;
+132 -55
View File
@@ -196,15 +196,11 @@ impl HistoryCell for UserHistoryCell {
lines
}
fn selection_projection(
&self,
width: u16,
mode: HistoryRenderMode,
) -> Option<crate::conversation_selection::CellSelectionProjection> {
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
let lines = self.raw_lines();
selection_projection_from_lines(
selection_contribution_from_semantic_text(
selection_text_from_lines(&lines),
lines,
width,
@@ -225,7 +221,7 @@ impl HistoryCell for UserHistoryCell {
}
sections.push(message.to_string());
}
selection_projection_from_lines(
selection_contribution_from_semantic_text(
sections.join("\n"),
self.display_lines(width),
width,
@@ -308,18 +304,14 @@ impl HistoryCell for ReasoningSummaryCell {
}
}
fn selection_projection(
&self,
width: u16,
mode: HistoryRenderMode,
) -> Option<crate::conversation_selection::CellSelectionProjection> {
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
if self.transcript_only {
return None;
return SelectionContribution::Transparent;
}
match mode {
HistoryRenderMode::Raw => {
let lines = self.raw_lines();
selection_projection_from_lines(
selection_contribution_from_semantic_text(
selection_text_from_lines(&lines),
lines,
width,
@@ -327,18 +319,21 @@ impl HistoryCell for ReasoningSummaryCell {
)
}
HistoryRenderMode::Rich => {
if crate::markdown_render::selection_text_contains_table(&self.content) {
return None;
}
selection_projection_from_lines(
crate::markdown_render::render_markdown_selection_text(
&self.content,
Some(self.cwd.as_path()),
),
let Some(markdown_width) =
crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2)
else {
return SelectionContribution::Transparent;
};
crate::markdown_render::render_markdown_selection_projection(
&self.content,
markdown_width,
Some(self.cwd.as_path()),
self.display_lines(width),
width,
/*first_row_prefix_columns*/ 2,
/*outer_prefix_columns*/ 2,
)
.map(SelectionContribution::Selectable)
.unwrap_or(SelectionContribution::Transparent)
}
}
}
@@ -348,6 +343,7 @@ impl HistoryCell for ReasoningSummaryCell {
pub(crate) struct AgentMessageCell {
lines: Vec<HyperlinkLine>,
is_first_line: bool,
selection_fragment: Option<StreamSelectionFragment>,
}
impl AgentMessageCell {
@@ -356,13 +352,19 @@ impl AgentMessageCell {
Self {
lines: plain_hyperlink_lines(lines),
is_first_line,
selection_fragment: None,
}
}
pub(crate) fn new_hyperlink_lines(lines: Vec<HyperlinkLine>, is_first_line: bool) -> Self {
pub(crate) fn new_source_backed_hyperlink_lines(
lines: Vec<HyperlinkLine>,
is_first_line: bool,
selection_fragment: Option<StreamSelectionFragment>,
) -> Self {
Self {
lines,
is_first_line,
selection_fragment,
}
}
}
@@ -399,26 +401,47 @@ impl HistoryCell for AgentMessageCell {
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(visible_lines(self.lines.clone()))
self.selection_fragment
.as_ref()
.map(|fragment| raw_lines_from_source(fragment.text()))
.unwrap_or_else(|| plain_lines(visible_lines(self.lines.clone())))
}
fn selection_projection(
&self,
width: u16,
mode: HistoryRenderMode,
) -> Option<crate::conversation_selection::CellSelectionProjection> {
match mode {
HistoryRenderMode::Rich => None,
HistoryRenderMode::Raw => {
let lines = self.raw_lines();
selection_projection_from_lines(
selection_text_from_lines(&lines),
lines,
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
let Some(fragment) = self.selection_fragment.as_ref() else {
return SelectionContribution::Transparent;
};
let projection = match mode {
HistoryRenderMode::Rich => {
let Some(body_width) =
crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2)
else {
return SelectionContribution::Transparent;
};
fragment.projection_for_display(
u16::try_from(body_width).unwrap_or(u16::MAX),
self.display_lines(width),
width,
/*first_row_prefix_columns*/ 0,
/*outer_prefix_columns*/ 2,
)
}
}
HistoryRenderMode::Raw => fragment.projection_for_display(
width,
self.raw_lines(),
width,
/*outer_prefix_columns*/ 0,
),
};
projection
.map(|projection| {
if self.is_stream_continuation() {
projection.with_separator_before("")
} else {
projection
}
})
.map(SelectionContribution::Selectable)
.unwrap_or(SelectionContribution::Transparent)
}
fn is_stream_continuation(&self) -> bool {
@@ -491,15 +514,11 @@ impl HistoryCell for AgentMarkdownCell {
raw_lines_from_source(&self.markdown_source)
}
fn selection_projection(
&self,
width: u16,
mode: HistoryRenderMode,
) -> Option<crate::conversation_selection::CellSelectionProjection> {
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
let lines = self.raw_lines();
selection_projection_from_lines(
selection_contribution_from_semantic_text(
selection_text_from_lines(&lines),
lines,
width,
@@ -508,18 +527,21 @@ impl HistoryCell for AgentMarkdownCell {
}
HistoryRenderMode::Rich => {
let normalized = crate::markdown::unwrap_markdown_fences(&self.markdown_source);
if crate::markdown_render::selection_text_contains_table(&normalized) {
return None;
}
selection_projection_from_lines(
crate::markdown_render::render_markdown_selection_text(
&normalized,
Some(self.cwd.as_path()),
),
let Some(markdown_width) =
crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2)
else {
return SelectionContribution::Transparent;
};
crate::markdown_render::render_markdown_selection_projection(
&normalized,
markdown_width,
Some(self.cwd.as_path()),
self.display_lines(width),
width,
/*first_row_prefix_columns*/ 2,
/*outer_prefix_columns*/ 2,
)
.map(SelectionContribution::Selectable)
.unwrap_or(SelectionContribution::Transparent)
}
}
}
@@ -534,13 +556,28 @@ impl HistoryCell for AgentMarkdownCell {
pub(crate) struct StreamingAgentTailCell {
lines: Vec<HyperlinkLine>,
is_first_line: bool,
selection_fragment: Option<StreamSelectionFragment>,
}
impl StreamingAgentTailCell {
#[cfg(test)]
pub(crate) fn new(lines: Vec<HyperlinkLine>, is_first_line: bool) -> Self {
Self {
lines,
is_first_line,
selection_fragment: None,
}
}
pub(crate) fn new_source_backed(
lines: Vec<HyperlinkLine>,
is_first_line: bool,
selection_fragment: Option<StreamSelectionFragment>,
) -> Self {
Self {
lines,
is_first_line,
selection_fragment,
}
}
}
@@ -581,7 +618,47 @@ impl HistoryCell for StreamingAgentTailCell {
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(/*width*/ u16::MAX))
self.selection_fragment
.as_ref()
.map(|fragment| raw_lines_from_source(fragment.text()))
.unwrap_or_else(|| plain_lines(visible_lines(self.lines.clone())))
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
let Some(fragment) = self.selection_fragment.as_ref() else {
return SelectionContribution::Transparent;
};
let projection = match mode {
HistoryRenderMode::Rich => {
let Some(body_width) =
crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2)
else {
return SelectionContribution::Transparent;
};
fragment.projection_for_display(
u16::try_from(body_width).unwrap_or(u16::MAX),
self.display_lines(width),
width,
/*outer_prefix_columns*/ 2,
)
}
HistoryRenderMode::Raw => fragment.projection_for_display(
width,
self.raw_lines(),
width,
/*outer_prefix_columns*/ 0,
),
};
projection
.map(|projection| {
if self.is_stream_continuation() {
projection.with_separator_before("")
} else {
projection
}
})
.map(SelectionContribution::Selectable)
.unwrap_or(SelectionContribution::Transparent)
}
fn is_stream_continuation(&self) -> bool {
+10 -49
View File
@@ -10,7 +10,6 @@
//! bumps the active-cell revision tracked by `ChatWidget`, so the cache key changes whenever the
//! rendered transcript output can change.
use crate::conversation_selection::CellSelectionProjection;
use crate::diff_model::FileChange;
use crate::diff_render::create_diff_summary;
use crate::diff_render::display_path_for;
@@ -106,7 +105,9 @@ use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use url::Url;
const RAW_DIFF_SUMMARY_WIDTH: usize = 10_000;
// Patch selection reuses the raw renderer as its unwrapped semantic source. Use an effectively
// unbounded width so even very long authored lines never acquire synthetic clipboard newlines.
const RAW_DIFF_SUMMARY_WIDTH: usize = usize::MAX;
const RAW_TOOL_OUTPUT_WIDTH: usize = 10_000;
mod approvals;
@@ -120,6 +121,7 @@ mod patches;
mod plans;
mod request_user_input;
mod search;
mod selection;
mod separators;
mod session;
@@ -136,6 +138,7 @@ pub(crate) use patches::*;
pub(crate) use plans::*;
pub(crate) use request_user_input::*;
pub(crate) use search::*;
pub(crate) use selection::*;
pub(crate) use separators::*;
pub(crate) use session::*;
@@ -178,28 +181,6 @@ pub(crate) fn plain_lines(lines: impl IntoIterator<Item = Line<'static>>) -> Vec
.collect()
}
pub(crate) fn selection_text_from_lines(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn selection_projection_from_lines(
text: String,
lines: Vec<Line<'static>>,
width: u16,
first_row_prefix_columns: u16,
) -> Option<CellSelectionProjection> {
CellSelectionProjection::from_display_lines(text, lines, width, first_row_prefix_columns)
}
/// A single renderable unit of conversation history.
///
/// Each cell produces logical `Line`s and reports how many viewport
@@ -247,32 +228,12 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
}
}
/// Maps rendered cell coordinates back to semantic text for app-owned mouse selection.
/// Describes the semantic text this cell contributes to app-owned mouse selection.
///
/// Raw mode uses the cell's copy-friendly lines directly. Rich mode is selectable by default
/// only when its visible text exactly matches those canonical lines; cells with presentation
/// chrome or alternate semantics must provide an explicit projection.
fn selection_projection(
&self,
width: u16,
mode: HistoryRenderMode,
) -> Option<CellSelectionProjection> {
let raw_lines = self.raw_lines();
let text = selection_text_from_lines(&raw_lines);
let display_lines = match mode {
HistoryRenderMode::Rich => visible_lines(self.display_hyperlink_lines(width)),
HistoryRenderMode::Raw => raw_lines,
};
if mode == HistoryRenderMode::Rich && selection_text_from_lines(&display_lines) != text {
return None;
}
selection_projection_from_lines(
text,
display_lines,
width,
/*first_row_prefix_columns*/ 0,
)
}
/// Implementations must explicitly choose between source-backed selectable text and
/// presentation-only output. Transparent cells may be crossed without contributing clipboard
/// text.
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution;
/// Returns the number of viewport rows needed to render this cell.
///
+72 -2
View File
@@ -17,6 +17,18 @@ impl UpdateAvailableHistoryCell {
update_action,
}
}
fn rich_selection_text(&self) -> String {
let update_instruction = if let Some(update_action) = self.update_action {
format!("Run {} to update.", update_action.command_str())
} else {
"See https://github.com/openai/codex for installation options.".to_string()
};
format!(
"Update available! {CODEX_CLI_VERSION} -> {}\n{update_instruction}\n\nSee full release notes:\nhttps://github.com/openai/codex/releases/latest",
self.latest_version
)
}
}
impl HistoryCell for UpdateAvailableHistoryCell {
@@ -79,6 +91,20 @@ impl HistoryCell for UpdateAvailableHistoryCell {
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.display_hyperlink_lines(width)
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => selection_contribution_from_semantic_text(
self.rich_selection_text(),
self.display_lines(width),
width,
/*first_row_prefix_columns*/ 0,
),
}
}
}
#[allow(clippy::disallowed_methods)]
pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell {
@@ -161,6 +187,24 @@ impl HistoryCell for SafetyAccessBlockCell {
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.display_hyperlink_lines(width)
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => {
let lines = self.display_lines(width);
let prefix_columns = vec![2; lines.len()];
selection_contribution_from_semantic_rows(
selection_text_from_lines(&self.raw_lines()),
lines,
width,
&prefix_columns,
)
}
}
}
}
#[derive(Debug)]
@@ -199,15 +243,40 @@ impl HistoryCell for DeprecationNoticeCell {
}
lines
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => {
let lines = self.display_lines(width);
let mut prefix_columns = vec![0; lines.len()];
if let Some(first) = prefix_columns.first_mut() {
*first = 2;
}
selection_contribution_from_semantic_rows(
selection_text_from_lines(&self.raw_lines()),
lines,
width,
&prefix_columns,
)
}
}
}
}
pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHistoryCell {
let mut selection_text = message.clone();
let mut line = vec!["• ".dim(), message.into()];
if let Some(hint) = hint {
selection_text.push(' ');
selection_text.push_str(&hint);
line.push(" ".into());
line.push(hint.dark_gray());
}
let lines: Vec<Line<'static>> = vec![line.into()];
PlainHistoryCell { lines }
PlainHistoryCell::new(lines)
.with_selection_text(selection_text, /*first_row_prefix_columns*/ vec![2])
}
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
@@ -215,5 +284,6 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
// before the text. VS16 is intentionally omitted to keep spacing tighter
// in terminals like Ghostty.
let lines: Vec<Line<'static>> = vec![vec![format!("■ {message}").red()].into()];
PlainHistoryCell { lines }
PlainHistoryCell::new(lines)
.with_selection_text(message, /*first_row_prefix_columns*/ vec![2])
}
+71 -6
View File
@@ -21,6 +21,36 @@ impl HistoryCell for PatchHistoryCell {
RAW_DIFF_SUMMARY_WIDTH,
))
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => {
let semantic = self
.raw_lines()
.iter()
.map(|line| {
let rendered = selection_text_from_lines(std::slice::from_ref(line));
rendered
.strip_prefix("• ")
.or_else(|| rendered.strip_prefix(" └ "))
.or_else(|| rendered.strip_prefix(" "))
.unwrap_or(&rendered)
.to_string()
})
.collect::<Vec<_>>()
.join("\n");
selection_contribution_from_semantic_text(
semantic,
self.display_lines(width),
width,
/*first_row_prefix_columns*/ 0,
)
}
}
}
}
/// Create a new `PendingPatch` cell that lists the file‑level summary of
/// a proposed patch. The summary lines should already be formatted (e.g.
@@ -58,7 +88,29 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
lines.extend(output.lines);
}
PlainHistoryCell { lines }
let mut selection_lines = Vec::with_capacity(lines.len());
let mut prefix_columns = Vec::with_capacity(lines.len());
for (index, line) in lines.iter().enumerate() {
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
let (selection_line, prefix_width) = if index == 0 {
("Failed to apply patch".to_string(), 2)
} else if let Some(content) = rendered
.strip_prefix(" └ ")
.or_else(|| rendered.strip_prefix(" "))
{
(content.to_string(), 4)
} else {
(rendered, 0)
};
selection_lines.push(selection_line);
prefix_columns.push(prefix_width);
}
PlainHistoryCell::new(lines).with_selection_text(selection_lines.join("\n"), prefix_columns)
}
pub(crate) fn new_view_image_tool_call(path: LegacyAppPathString, cwd: &Path) -> PlainHistoryCell {
@@ -68,12 +120,14 @@ pub(crate) fn new_view_image_tool_call(path: LegacyAppPathString, cwd: &Path) ->
.map(|path| display_path_for(path.as_path(), cwd))
.unwrap_or_else(|| path.into_string());
let selection_text = format!("Viewed Image\n{display_path}");
let lines: Vec<Line<'static>> = vec![
vec!["• ".dim(), "Viewed Image".bold()].into(),
vec![" └ ".dim(), display_path.dim()].into(),
];
PlainHistoryCell { lines }
PlainHistoryCell::new(lines)
.with_selection_text(selection_text, /*first_row_prefix_columns*/ vec![2, 4])
}
pub(crate) fn new_image_generation_call(
@@ -83,18 +137,29 @@ pub(crate) fn new_image_generation_call(
saved_path: Option<AbsolutePathBuf>,
) -> PlainHistoryCell {
let detail = revised_prompt.unwrap_or(call_id);
let heading = if status == "failed" {
vec!["✗ ".red().bold(), "Image generation failed".bold()].into()
let heading_text = if status == "failed" {
"Image generation failed"
} else {
vec!["• ".dim(), "Generated Image:".bold()].into()
"Generated Image:"
};
let heading = if status == "failed" {
vec!["✗ ".red().bold(), heading_text.bold()].into()
} else {
vec!["• ".dim(), heading_text.bold()].into()
};
let mut selection_lines = vec![heading_text.to_string(), detail.clone()];
let mut lines: Vec<Line<'static>> = vec![heading, vec![" └ ".dim(), detail.dim()].into()];
if let Some(saved_path) = saved_path {
let saved_path = Url::from_file_path(saved_path.as_path())
.map(|url| url.to_string())
.unwrap_or_else(|_| saved_path.display().to_string());
selection_lines.push(format!("Saved to: {saved_path}"));
lines.push(vec![" └ ".dim(), "Saved to: ".dim(), saved_path.into()].into());
}
PlainHistoryCell { lines }
let mut prefix_columns = vec![4; lines.len()];
if let Some(first) = prefix_columns.first_mut() {
*first = 2;
}
PlainHistoryCell::new(lines).with_selection_text(selection_lines.join("\n"), prefix_columns)
}
+299 -4
View File
@@ -1,6 +1,9 @@
//! Proposed-plan and plan-update history cells.
use super::*;
use crate::conversation_selection::CellSelectionProjection;
use crate::conversation_selection::CellSelectionProjectionPart;
use std::ops::Range;
/// Transient active-cell representation of the mutable tail of a proposed-plan stream.
///
@@ -11,13 +14,22 @@ use super::*;
pub(crate) struct StreamingPlanTailCell {
lines: Vec<HyperlinkLine>,
is_stream_continuation: bool,
selection_fragment: Option<StreamSelectionFragment>,
body_line_range: Range<usize>,
}
impl StreamingPlanTailCell {
pub(crate) fn new(lines: Vec<HyperlinkLine>, is_stream_continuation: bool) -> Self {
pub(crate) fn new_source_backed(
lines: Vec<HyperlinkLine>,
is_stream_continuation: bool,
selection_fragment: Option<StreamSelectionFragment>,
body_line_range: Range<usize>,
) -> Self {
Self {
lines,
is_stream_continuation,
selection_fragment,
body_line_range,
}
}
}
@@ -36,7 +48,26 @@ impl HistoryCell for StreamingPlanTailCell {
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(visible_lines(self.lines.clone()))
self.selection_fragment
.as_ref()
.map(|fragment| raw_lines_from_source(fragment.text()))
.unwrap_or_else(|| plain_lines(visible_lines(self.lines.clone())))
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
self.selection_fragment
.as_ref()
.map(|fragment| {
stream_plan_selection_contribution(
&self.lines,
self.body_line_range.clone(),
fragment,
self.is_stream_continuation,
width,
mode,
)
})
.unwrap_or(SelectionContribution::Transparent)
}
fn is_stream_continuation(&self) -> bool {
@@ -65,13 +96,32 @@ pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPl
///
/// Stream cells are display fragments, not source-backed history. They should be replaced by
/// `ProposedPlanCell` during consolidation before relying on resize reflow for finalized history.
#[cfg(test)]
pub(crate) fn new_proposed_plan_stream(
lines: Vec<impl Into<HyperlinkLine>>,
is_stream_continuation: bool,
) -> ProposedPlanStreamCell {
let lines = lines.into_iter().map(Into::into).collect::<Vec<_>>();
let body_line_range = 0..lines.len();
ProposedPlanStreamCell {
lines: lines.into_iter().map(Into::into).collect(),
lines,
is_stream_continuation,
selection_fragment: None,
body_line_range,
}
}
pub(crate) fn new_source_backed_proposed_plan_stream(
lines: Vec<HyperlinkLine>,
is_stream_continuation: bool,
selection_fragment: Option<StreamSelectionFragment>,
body_line_range: Range<usize>,
) -> ProposedPlanStreamCell {
ProposedPlanStreamCell {
lines,
is_stream_continuation,
selection_fragment,
body_line_range,
}
}
@@ -95,6 +145,113 @@ pub(crate) struct ProposedPlanCell {
pub(crate) struct ProposedPlanStreamCell {
lines: Vec<HyperlinkLine>,
is_stream_continuation: bool,
selection_fragment: Option<StreamSelectionFragment>,
body_line_range: Range<usize>,
}
fn stream_plan_selection_contribution(
lines: &[HyperlinkLine],
body_line_range: Range<usize>,
fragment: &StreamSelectionFragment,
is_stream_continuation: bool,
width: u16,
mode: HistoryRenderMode,
) -> SelectionContribution {
if width == 0 {
return SelectionContribution::Transparent;
}
if mode == HistoryRenderMode::Raw {
return fragment
.projection_for_display(
width,
raw_lines_from_source(fragment.text()),
width,
/*outer_prefix_columns*/ 0,
)
.map(|projection| {
if is_stream_continuation {
projection.with_separator_before("")
} else {
projection
}
})
.map(SelectionContribution::Selectable)
.unwrap_or(SelectionContribution::Transparent);
}
let body_start = body_line_range.start.min(lines.len());
let body_end = body_line_range.end.min(lines.len()).max(body_start);
let body_width = width.saturating_sub(/*rhs*/ 4).max(/*other*/ 1);
let body_projection = fragment.projection_for_display(
body_width,
visible_lines(lines[body_start..body_end].to_vec()),
width,
/*outer_prefix_columns*/ 2,
);
let display_row_count = |lines: &[HyperlinkLine]| {
visible_lines(lines.to_vec())
.into_iter()
.map(|line| {
Paragraph::new(line)
.wrap(Wrap { trim: false })
.line_count(width)
.max(/*other*/ 1)
})
.sum::<usize>()
};
let leading_rows = display_row_count(&lines[..body_start]);
let trailing_rows = display_row_count(&lines[body_end..]);
let mut parts = Vec::new();
if !is_stream_continuation && let Some(header_line) = lines.first() {
let header_rows = display_row_count(std::slice::from_ref(header_line));
let header = CellSelectionProjection::from_display_lines(
"Proposed Plan".to_string(),
visible_lines(vec![header_line.clone()]),
width,
/*first_row_prefix_columns*/ 2,
);
parts.push(
header
.map(CellSelectionProjectionPart::Selectable)
.unwrap_or(CellSelectionProjectionPart::Transparent {
row_count: header_rows,
}),
);
let remaining_leading_rows = leading_rows.saturating_sub(header_rows);
if remaining_leading_rows > 0 {
parts.push(CellSelectionProjectionPart::Transparent {
row_count: remaining_leading_rows,
});
}
} else if leading_rows > 0 {
parts.push(CellSelectionProjectionPart::Transparent {
row_count: leading_rows,
});
}
parts.push(
body_projection
.map(CellSelectionProjectionPart::Selectable)
.unwrap_or(CellSelectionProjectionPart::Transparent {
row_count: display_row_count(&lines[body_start..body_end]),
}),
);
if trailing_rows > 0 {
parts.push(CellSelectionProjectionPart::Transparent {
row_count: trailing_rows,
});
}
CellSelectionProjection::compose(
parts, /*blank_rows_between*/ 0, /*text_separator*/ "\n\n",
)
.map(|projection| {
if is_stream_continuation {
projection.with_separator_before("")
} else {
projection
}
})
.map(SelectionContribution::Selectable)
.unwrap_or(SelectionContribution::Transparent)
}
impl HistoryCell for ProposedPlanCell {
@@ -133,6 +290,66 @@ impl HistoryCell for ProposedPlanCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
raw_lines_from_source(&self.plan_markdown)
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => {
let normalized = crate::markdown::unwrap_markdown_fences(&self.plan_markdown);
let markdown_width = width.saturating_sub(/*rhs*/ 4).max(/*other*/ 1) as usize;
let mut body_lines = crate::markdown::render_markdown_agent_with_links_and_cwd(
&self.plan_markdown,
Some(markdown_width),
Some(self.cwd.as_path()),
);
let body_row_count = body_lines.len().max(/*other*/ 1);
let body_projection = if body_lines.is_empty() {
let lines = vec![Line::from(" (empty)")];
CellSelectionProjection::from_display_lines(
"(empty)".to_string(),
lines,
width,
/*first_row_prefix_columns*/ 2,
)
} else {
body_lines = prefix_hyperlink_lines(body_lines, " ".into(), " ".into());
crate::markdown_render::render_markdown_selection_projection(
&normalized,
markdown_width,
Some(self.cwd.as_path()),
visible_lines(body_lines),
width,
/*outer_prefix_columns*/ 2,
)
};
let header = CellSelectionProjection::from_display_lines(
"Proposed Plan".to_string(),
vec![vec!["• ".dim(), "Proposed Plan".bold()].into()],
width,
/*first_row_prefix_columns*/ 2,
);
let parts = vec![
header
.map(CellSelectionProjectionPart::Selectable)
.unwrap_or(CellSelectionProjectionPart::Transparent { row_count: 1 }),
CellSelectionProjectionPart::Transparent { row_count: 2 },
body_projection
.map(CellSelectionProjectionPart::Selectable)
.unwrap_or(CellSelectionProjectionPart::Transparent {
row_count: body_row_count,
}),
CellSelectionProjectionPart::Transparent { row_count: 1 },
];
CellSelectionProjection::compose(
parts, /*blank_rows_between*/ 0, /*text_separator*/ "\n\n",
)
.map(SelectionContribution::Selectable)
.unwrap_or(SelectionContribution::Transparent)
}
}
}
}
impl HistoryCell for ProposedPlanStreamCell {
@@ -149,7 +366,26 @@ impl HistoryCell for ProposedPlanStreamCell {
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(visible_lines(self.lines.clone()))
self.selection_fragment
.as_ref()
.map(|fragment| raw_lines_from_source(fragment.text()))
.unwrap_or_else(|| plain_lines(visible_lines(self.lines.clone())))
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
self.selection_fragment
.as_ref()
.map(|fragment| {
stream_plan_selection_contribution(
&self.lines,
self.body_line_range.clone(),
fragment,
self.is_stream_continuation,
width,
mode,
)
})
.unwrap_or(SelectionContribution::Transparent)
}
fn is_stream_continuation(&self) -> bool {
@@ -235,4 +471,63 @@ impl HistoryCell for PlanUpdateCell {
}
lines
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => {
let mut lines = vec!["Updated Plan".to_string()];
if let Some(explanation) = self
.explanation
.as_ref()
.map(|explanation| explanation.trim())
.filter(|explanation| !explanation.is_empty())
{
lines.push(explanation.to_string());
}
if self.plan.is_empty() {
lines.push("(no steps provided)".to_string());
} else {
lines.extend(self.plan.iter().map(|item| item.step.clone()));
}
let display_lines = self.display_lines(width);
let prefix_columns = display_lines
.iter()
.enumerate()
.map(|(index, line)| {
if index == 0 {
return 2;
}
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
let after_outer_prefix = rendered
.strip_prefix(" └ ")
.or_else(|| rendered.strip_prefix(" "));
match after_outer_prefix {
Some(rest)
if rest.starts_with("✔ ")
|| rest.starts_with("□ ")
|| rest.starts_with(" ") =>
{
6
}
Some(_) => 4,
None => 0,
}
})
.collect::<Vec<_>>();
selection_contribution_from_semantic_rows(
lines.join("\n"),
display_lines,
width,
&prefix_columns,
)
}
}
}
}
@@ -10,6 +10,69 @@ pub(crate) struct RequestUserInputResultCell {
pub(crate) interrupted: bool,
}
impl RequestUserInputResultCell {
fn rich_selection_text(&self) -> String {
let total = self.questions.len();
let answered = self
.questions
.iter()
.filter(|question| {
self.answers
.get(&question.id)
.is_some_and(|answer| !answer.answers.is_empty())
})
.count();
let unanswered = total.saturating_sub(answered);
let interrupted = if self.interrupted {
" (interrupted)"
} else {
""
};
let mut lines = vec![format!(
"Questions {answered}/{total} answered{interrupted}"
)];
for question in &self.questions {
let answer = self.answers.get(&question.id);
let answer_missing = answer.is_none_or(|answer| answer.answers.is_empty());
let mut question_text = question.question.clone();
if answer_missing {
question_text.push_str(" (unanswered)");
}
lines.push(question_text);
let Some(answer) = answer.filter(|answer| !answer.answers.is_empty()) else {
continue;
};
if question.is_secret {
lines.push("answer: ••••••".to_string());
continue;
}
let (options, note) = split_request_user_input_answer(answer);
lines.extend(
options
.into_iter()
.map(|option| format!("answer: {option}")),
);
if let Some(note) = note {
let label = if question.options.is_some() {
"note"
} else {
"answer"
};
lines.push(format!("{label}: {note}"));
}
}
if self.interrupted && unanswered > 0 {
lines.push(format!("interrupted with {unanswered} unanswered"));
}
lines.join("\n")
}
}
impl HistoryCell for RequestUserInputResultCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let width = width.max(1) as usize;
@@ -149,6 +212,49 @@ impl HistoryCell for RequestUserInputResultCell {
}
lines
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => {
let lines = self.display_lines(width);
let prefix_columns = lines
.iter()
.enumerate()
.map(|(index, line)| {
if index == 0 {
return 2;
}
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
if rendered.starts_with(" ") {
12
} else if rendered.starts_with(" ") {
10
} else if rendered.starts_with(" • ")
|| rendered.starts_with(" ↳ ")
|| rendered.starts_with(" ")
{
4
} else {
0
}
})
.collect::<Vec<_>>();
selection_contribution_from_semantic_rows(
self.rich_selection_text(),
lines,
width,
&prefix_columns,
)
}
}
}
}
/// Wrap a plain string with textwrap and prefix each line, while applying a style to the content.
+14
View File
@@ -120,6 +120,20 @@ impl HistoryCell for WebSearchCell {
vec![Line::from(format!("{header}{separator}{detail}"))]
}
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => selection_contribution_from_semantic_text(
selection_text_from_lines(&self.raw_lines()),
self.display_lines(width),
width,
/*first_row_prefix_columns*/ 2,
),
}
}
}
pub(crate) fn new_active_web_search_call(
+124
View File
@@ -0,0 +1,124 @@
//! Selection contracts and projection builders shared by history cells.
use crate::conversation_selection::CellSelectionProjection;
use ratatui::text::Line;
/// Describes whether a history cell contributes semantic text to mouse selection.
///
/// Selectable cells map their rendered rows back to canonical copy text. Transparent cells are
/// presentation-only and may be crossed without contributing text to the clipboard.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum SelectionContribution {
Selectable(CellSelectionProjection),
Transparent,
}
/// Source-backed semantic projection for one emitted or mutable streaming fragment.
///
/// The controller slices this from its full source projection using rendered-line boundaries.
/// Cells can reuse the exact mapping at the original body width or realign the same canonical text
/// after a resize without ever deriving clipboard text from joined terminal rows.
#[derive(Clone, Debug)]
pub(crate) struct StreamSelectionFragment {
projection: CellSelectionProjection,
body_width: u16,
}
impl StreamSelectionFragment {
pub(crate) fn new(projection: CellSelectionProjection, body_width: u16) -> Self {
Self {
projection,
body_width,
}
}
pub(crate) fn projection_for_display(
&self,
body_width: u16,
display_lines: Vec<Line<'static>>,
display_width: u16,
outer_prefix_columns: u16,
) -> Option<CellSelectionProjection> {
Some(if self.body_width == body_width {
self.projection
.clone()
.with_column_offset(outer_prefix_columns)
} else {
CellSelectionProjection::from_display_lines(
self.projection.text().to_string(),
display_lines,
display_width,
outer_prefix_columns,
)?
})
}
pub(crate) fn text(&self) -> &str {
self.projection.text()
}
}
impl SelectionContribution {
pub(crate) fn into_projection(self) -> Option<CellSelectionProjection> {
match self {
Self::Selectable(projection) => Some(projection),
Self::Transparent => None,
}
}
}
pub(crate) fn selection_text_from_lines(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
/// Builds a selectable contribution by aligning rendered cells with canonical semantic text.
pub(crate) fn selection_contribution_from_semantic_text(
text: String,
lines: Vec<Line<'static>>,
width: u16,
first_row_prefix_columns: u16,
) -> SelectionContribution {
match CellSelectionProjection::from_display_lines(text, lines, width, first_row_prefix_columns)
{
Some(projection) => SelectionContribution::Selectable(projection),
None => SelectionContribution::Transparent,
}
}
/// Builds a selectable contribution with presentation-prefix widths specified per logical line.
pub(crate) fn selection_contribution_from_semantic_rows(
text: String,
lines: Vec<Line<'static>>,
width: u16,
first_row_prefix_columns: &[u16],
) -> SelectionContribution {
match CellSelectionProjection::from_display_lines_with_prefixes(
text,
lines,
width,
first_row_prefix_columns,
) {
Some(projection) => SelectionContribution::Selectable(projection),
None => SelectionContribution::Transparent,
}
}
/// Builds a contribution whose rendered text is also its canonical clipboard text.
pub(crate) fn selection_contribution_from_display_lines(
lines: Vec<Line<'static>>,
width: u16,
) -> SelectionContribution {
let text = selection_text_from_lines(&lines);
selection_contribution_from_semantic_text(
text, lines, width, /*first_row_prefix_columns*/ 0,
)
}
@@ -71,6 +71,24 @@ impl HistoryCell for FinalMessageSeparator {
vec![Line::from(label_parts.join(" • "))]
}
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
let lines = self.raw_lines();
match mode {
HistoryRenderMode::Raw => selection_contribution_from_display_lines(lines, width),
HistoryRenderMode::Rich => {
let semantic = selection_text_from_lines(&lines);
let semantic_width = usize::from(width.saturating_sub(2));
let (visible_semantic, _, _) = take_prefix_by_width(&semantic, semantic_width);
selection_contribution_from_semantic_text(
visible_semantic,
self.display_lines(width),
width,
/*first_row_prefix_columns*/ 2,
)
}
}
}
}
pub(crate) fn runtime_metrics_label(summary: RuntimeMetricsSummary) -> Option<String> {
+93 -4
View File
@@ -79,13 +79,13 @@ pub(crate) fn padded_emoji(emoji: &str) -> String {
}
#[derive(Debug)]
struct TooltipHistoryCell {
pub(super) struct TooltipHistoryCell {
tip: String,
cwd: PathBuf,
}
impl TooltipHistoryCell {
fn new(tip: String, cwd: &Path) -> Self {
pub(super) fn new(tip: String, cwd: &Path) -> Self {
Self {
tip,
cwd: cwd.to_path_buf(),
@@ -114,6 +114,31 @@ impl HistoryCell for TooltipHistoryCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
vec![Line::from(format!("Tip: {}", self.tip))]
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => {
let source = format!("**Tip:** {}", self.tip);
let indent_width = UnicodeWidthStr::width(" ");
let markdown_width = usize::from(width.max(/*other*/ 1))
.saturating_sub(indent_width)
.max(/*other*/ 1);
crate::markdown_render::render_markdown_selection_projection(
&source,
markdown_width,
Some(self.cwd.as_path()),
self.display_lines(width),
width,
/*outer_prefix_columns*/ 2,
)
.map(SelectionContribution::Selectable)
.unwrap_or(SelectionContribution::Transparent)
}
}
}
}
#[derive(Debug)]
@@ -135,6 +160,10 @@ impl HistoryCell for SessionInfoCell {
fn raw_lines(&self) -> Vec<Line<'static>> {
self.0.raw_lines()
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
self.0.selection_contribution(width, mode)
}
}
pub(crate) fn new_session_info(
@@ -194,7 +223,22 @@ pub(crate) fn new_session_info(
]),
];
parts.push(Box::new(PlainHistoryCell { lines: help_lines }));
let help_selection_text = [
"To get started, describe a task or try one of these commands:",
"",
"/init - create an AGENTS.md file with instructions for Codex",
"/status - show current session configuration",
"/permissions - choose what Codex is allowed to do",
"/model - choose what model and reasoning effort to use",
"/review - review any changes and find issues",
]
.join("\n");
parts.push(Box::new(
PlainHistoryCell::new(help_lines).with_selection_text(
help_selection_text,
/*first_row_prefix_columns*/ vec![2, 0, 2, 2, 2, 2, 2],
),
));
} else {
if config.show_tooltips
&& let Some(tooltips) = tooltip_override
@@ -209,7 +253,7 @@ pub(crate) fn new_session_info(
format!("requested: {requested_model}").into(),
format!("used: {}", session.model).into(),
];
parts.push(Box::new(PlainHistoryCell { lines }));
parts.push(Box::new(PlainHistoryCell::new(lines)));
}
}
@@ -420,4 +464,49 @@ impl HistoryCell for SessionHeaderHistoryCell {
}
lines
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
match mode {
HistoryRenderMode::Raw => {
selection_contribution_from_display_lines(self.raw_lines(), width)
}
HistoryRenderMode::Rich => {
let display_lines = self.display_lines(width);
let Some(inner_width) = card_inner_width(width, SESSION_HEADER_MAX_INNER_WIDTH)
else {
return SelectionContribution::Transparent;
};
let mut model = self.model.clone();
if let Some(reasoning) = self.reasoning_label() {
model.push(' ');
model.push_str(reasoning);
}
if self.show_fast_status {
model.push_str(" fast");
}
let dir_prefix_width = if self.yolo_mode {
UnicodeWidthStr::width("directory: ")
} else {
UnicodeWidthStr::width("directory: ")
};
let directory =
self.format_directory(Some(inner_width.saturating_sub(dir_prefix_width)));
let mut semantic_lines = vec![
format!("OpenAI Codex (v{})", self.version),
String::new(),
format!("model: {model} /model to change"),
format!("directory: {directory}"),
];
if self.yolo_mode {
semantic_lines.push("permissions: YOLO mode".to_string());
}
selection_contribution_from_semantic_text(
semantic_lines.join("\n"),
display_lines,
width,
/*first_row_prefix_columns*/ 0,
)
}
}
}
}
+493
View File
@@ -1,6 +1,7 @@
//! Coverage for history-cell rendering, wrapping, and transcript behavior.
use super::*;
use crate::conversation_selection::CellSelectionProjection;
use crate::exec_cell::CommandOutput;
use crate::exec_cell::ExecCall;
use crate::exec_cell::ExecCell;
@@ -47,6 +48,142 @@ fn test_cwd() -> PathBuf {
std::env::temp_dir()
}
#[test]
fn display_backed_selection_contribution_uses_visible_text() {
let contribution = selection_contribution_from_display_lines(
vec![Line::from("alpha"), Line::from("beta")],
/*width*/ 80,
);
let SelectionContribution::Selectable(projection) = contribution else {
panic!("display text should be selectable");
};
assert_eq!(projection.text(), "alpha\nbeta");
}
#[test]
fn semantic_selection_contribution_excludes_presentation_prefix() {
let contribution = selection_contribution_from_semantic_text(
"alpha".to_string(),
vec![Line::from("• alpha")],
/*width*/ 80,
/*first_row_prefix_columns*/ 2,
);
let SelectionContribution::Selectable(projection) = contribution else {
panic!("semantic text should be selectable");
};
assert_eq!(projection.text(), "alpha");
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 0), None);
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 2), Some(0..1));
}
#[test]
fn semantic_selection_contribution_supports_per_line_prefixes() {
let contribution = selection_contribution_from_semantic_rows(
" one\ntwo".to_string(),
vec![Line::from("> one"), Line::from(" two")],
/*width*/ 80,
/*first_row_prefix_columns*/ &[2, 4],
);
let SelectionContribution::Selectable(projection) = contribution else {
panic!("semantic rows should be selectable");
};
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 1), None);
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 2), Some(0..1));
assert_eq!(projection.hit(/*row*/ 1, /*column*/ 3), None);
assert_eq!(projection.hit(/*row*/ 1, /*column*/ 4), Some(5..6));
}
#[test]
fn empty_selection_contribution_is_transparent() {
assert_eq!(
selection_contribution_from_display_lines(Vec::new(), /*width*/ 80),
SelectionContribution::Transparent
);
}
#[test]
fn plain_cell_can_use_semantic_selection_text() {
let cell = PlainHistoryCell::new(vec![Line::from("• alpha"), Line::from(" beta")])
.with_selection_text(
"alpha\nbeta".to_string(),
/*first_row_prefix_columns*/ vec![2, 2],
);
for mode in [HistoryRenderMode::Rich, HistoryRenderMode::Raw] {
let SelectionContribution::Selectable(projection) =
cell.selection_contribution(/*width*/ 80, mode)
else {
panic!("semantic plain text should be selectable");
};
assert_eq!(projection.text(), "alpha\nbeta");
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 0), None);
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 2), Some(0..1));
}
}
#[test]
fn prefixed_cell_selection_excludes_each_rendered_prefix() {
let cell = PrefixedWrappedHistoryCell::new(
Text::from(vec![Line::from("alpha"), Line::from("beta")]),
"> ",
".... ",
);
let SelectionContribution::Selectable(projection) =
cell.selection_contribution(/*width*/ 80, HistoryRenderMode::Rich)
else {
panic!("prefixed text should be selectable");
};
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 1), None);
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 2), Some(0..1));
assert_eq!(projection.hit(/*row*/ 1, /*column*/ 4), None);
assert_eq!(projection.hit(/*row*/ 1, /*column*/ 5), Some(6..7));
}
#[test]
fn composite_selection_preserves_blank_separator_row() {
let cell = CompositeHistoryCell::new(vec![
Box::new(PlainHistoryCell::new(vec![Line::from("alpha")])),
Box::new(PlainHistoryCell::new(vec![Line::from("beta")])),
]);
let SelectionContribution::Selectable(projection) =
cell.selection_contribution(/*width*/ 80, HistoryRenderMode::Rich)
else {
panic!("composed text should be selectable");
};
assert_eq!(projection.text(), "alpha\n\nbeta");
assert_eq!(projection.rows().len(), 3);
assert_eq!(projection.hit(/*row*/ 0, /*column*/ 0), Some(0..1));
assert_eq!(projection.hit(/*row*/ 1, /*column*/ 0), None);
assert_eq!(projection.hit(/*row*/ 2, /*column*/ 0), Some(7..8));
}
#[test]
fn composite_selection_skips_transparent_child_text_but_preserves_its_rows() {
let cell = CompositeHistoryCell::new(vec![
Box::new(PlainHistoryCell::new(vec![Line::from("alpha")])),
Box::new(AgentMessageCell::new(
vec![Line::from("hidden")],
/*is_first_line*/ false,
)),
Box::new(PlainHistoryCell::new(vec![Line::from("beta")])),
]);
let SelectionContribution::Selectable(projection) =
cell.selection_contribution(/*width*/ 80, HistoryRenderMode::Rich)
else {
panic!("selectable children should be composed");
};
assert_eq!(projection.text(), "alpha\n\nbeta");
assert_eq!(projection.rows().len(), 5);
assert_eq!(projection.hit(/*row*/ 2, /*column*/ 2), None);
assert_eq!(projection.hit(/*row*/ 4, /*column*/ 0), Some(7..8));
}
#[test]
fn streaming_agent_tail_blank_line_uses_one_viewport_row() {
let cell = StreamingAgentTailCell::new(
@@ -163,6 +300,12 @@ fn render_transcript(cell: &dyn HistoryCell) -> Vec<String> {
render_lines(&cell.transcript_lines(u16::MAX))
}
fn selectable_text(cell: &dyn HistoryCell, width: u16, mode: HistoryRenderMode) -> Option<String> {
cell.selection_contribution(width, mode)
.into_projection()
.map(|projection| projection.text().to_string())
}
fn assert_unstyled_lines(lines: &[Line<'static>]) {
for line in lines {
assert_eq!(line.style, Style::default());
@@ -918,8 +1061,13 @@ fn mcp_tools_output_from_statuses_renders_status_only_servers() {
let cell =
new_mcp_tools_output_from_statuses(&statuses, McpServerStatusDetail::ToolsAndAuthOnly);
let rendered = render_lines(&cell.display_lines(/*width*/ 120)).join("\n");
let selected = selectable_text(&cell, /*width*/ 24, HistoryRenderMode::Rich)
.expect("MCP inventory should be selectable");
insta::assert_snapshot!(rendered);
assert!(selected.contains("MCP Tools\n\nplugin_docs\nAuth: Unsupported\nTools: lookup"));
assert!(!selected.contains('•'));
assert!(!selected.contains('🔌'));
}
#[test]
@@ -2399,6 +2547,33 @@ fn agent_markdown_cell_renders_source_at_different_widths() {
);
}
#[test]
fn agent_markdown_table_selection_excludes_layout_chrome_at_wide_and_narrow_widths() {
let source = "| Name | Description |\n| --- | --- |\n| alpha | a deliberately long description that wraps |\n";
let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd());
let expected = "Name\tDescription\nalpha\ta deliberately long description that wraps";
assert_eq!(
selectable_text(&cell, /*width*/ 80, HistoryRenderMode::Rich).as_deref(),
Some(expected)
);
assert_eq!(
selectable_text(&cell, /*width*/ 28, HistoryRenderMode::Rich).as_deref(),
Some(expected)
);
}
#[test]
fn agent_markdown_fenced_table_selection_uses_rendered_inline_text() {
let source = "```markdown\n| Kind | Target |\n| --- | --- |\n| **docs** | [guide](https://example.com/guide) |\n```";
let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd());
assert_eq!(
selectable_text(&cell, /*width*/ 80, HistoryRenderMode::Rich).as_deref(),
Some("Kind\tTarget\ndocs\tguide (https://example.com/guide)")
);
}
#[test]
fn agent_markdown_cell_does_not_split_words_after_inline_markdown() {
let source = "This paragraph is intentionally long so you can inspect soft wrapping behavior while also checking inline formatting like **bold text**, *italic text*, ***bold italic text***, `inline code`, ~~strikethrough~~, a [link to example.com](https://example.com), and a literal path like [README.md](/Users/felipe.coury/code/codex.fcoury-worktrees/README.md) without introducing manual line breaks.\n";
@@ -2622,3 +2797,321 @@ fn consolidation_walker_replaces_agent_message_cells() {
"second cell should be AgentMarkdownCell"
);
}
#[test]
fn unified_exec_interaction_selection_excludes_tree_gutters_and_soft_wraps() {
let stdin = "first input line that wraps at this width\n indented input";
let cell = UnifiedExecInteractionCell::new(Some("worker 7".to_string()), stdin.to_string());
assert_eq!(
selectable_text(&cell, /*width*/ 24, HistoryRenderMode::Rich),
Some(format!(
"Interacted with background terminal · worker 7\n{stdin}"
))
);
}
#[test]
fn request_user_input_selection_uses_visible_secret_mask_without_gutters() {
let questions = vec![
ToolRequestUserInputQuestion {
id: "target".to_string(),
header: "Target".to_string(),
question: "Which target should be used?".to_string(),
is_other: false,
is_secret: false,
options: None,
},
ToolRequestUserInputQuestion {
id: "secret".to_string(),
header: "Secret".to_string(),
question: "Secret value?".to_string(),
is_other: false,
is_secret: true,
options: None,
},
];
let answers = HashMap::from([
(
"target".to_string(),
ToolRequestUserInputAnswer {
answers: vec!["production".to_string()],
},
),
(
"secret".to_string(),
ToolRequestUserInputAnswer {
answers: vec!["not copied".to_string()],
},
),
]);
let cell = RequestUserInputResultCell {
questions,
answers,
interrupted: false,
};
assert_eq!(
selectable_text(&cell, /*width*/ 28, HistoryRenderMode::Rich),
Some(
"Questions 2/2 answered\nWhich target should be used?\nanswer: production\nSecret value?\nanswer: ••••••"
.to_string()
)
);
}
#[test]
fn informational_cells_expose_semantic_text_without_card_chrome() {
let info = new_info_event(
"Saved settings.".to_string(),
Some("Restart Codex.".to_string()),
);
assert_eq!(
selectable_text(&info, /*width*/ 40, HistoryRenderMode::Rich),
Some("Saved settings. Restart Codex.".to_string())
);
let error = new_error_event("Could not connect.".to_string());
assert_eq!(
selectable_text(&error, /*width*/ 40, HistoryRenderMode::Rich),
Some("Could not connect.".to_string())
);
let search = new_web_search_call(
"call-1".to_string(),
"selection behavior".to_string(),
WebSearchAction::Search {
query: Some("selection behavior".to_string()),
queries: None,
},
);
assert_eq!(
selectable_text(&search, /*width*/ 20, HistoryRenderMode::Rich),
Some("Searched the web for selection behavior".to_string())
);
let update =
UpdateAvailableHistoryCell::new("9.9.9".to_string(), Some(UpdateAction::StandaloneUnix));
let update_text = selectable_text(&update, /*width*/ 48, HistoryRenderMode::Rich)
.expect("update notice should be selectable");
assert!(update_text.starts_with(&format!("Update available! {CODEX_CLI_VERSION} -> 9.9.9")));
assert!(!update_text.contains('│'));
let safety = new_safety_access_block_event();
let safety_text = selectable_text(&safety, /*width*/ 42, HistoryRenderMode::Rich)
.expect("safety notice should be selectable");
assert!(safety_text.starts_with("This content can't be shown"));
assert!(!safety_text.contains('ⓘ'));
}
#[test]
fn image_and_patch_failure_selection_excludes_tree_gutters() {
let image = new_image_generation_call(
"call-image".to_string(),
"completed",
Some("A blue square".to_string()),
/*saved_path*/ None,
);
assert_eq!(
selectable_text(&image, /*width*/ 40, HistoryRenderMode::Rich),
Some("Generated Image:\nA blue square".to_string())
);
let patch_failure = new_patch_apply_failure("bad hunk\ntry again".to_string());
assert_eq!(
selectable_text(&patch_failure, /*width*/ 40, HistoryRenderMode::Rich),
Some("Failed to apply patch\nbad hunk\ntry again".to_string())
);
}
#[test]
fn plan_selection_uses_semantic_markdown_and_step_text() {
let proposed = new_proposed_plan("**Ship it**\n\nThen verify".to_string(), &test_cwd());
assert_eq!(
selectable_text(&proposed, /*width*/ 28, HistoryRenderMode::Rich),
Some("Proposed Plan\n\nShip it\n\nThen verify".to_string())
);
let update = new_plan_update(UpdatePlanArgs {
explanation: Some("Check the current behavior first.".to_string()),
plan: vec![
PlanItemArg {
step: "Inspect rendering".to_string(),
status: StepStatus::Completed,
},
PlanItemArg {
step: "Implement selection".to_string(),
status: StepStatus::InProgress,
},
],
});
assert_eq!(
selectable_text(&update, /*width*/ 24, HistoryRenderMode::Rich),
Some(
"Updated Plan\nCheck the current behavior first.\nInspect rendering\nImplement selection"
.to_string()
)
);
}
#[test]
fn source_backed_plan_and_tooltip_tables_are_selectable_without_chrome() {
let table = "| Name | Value |\n| --- | --- |\n| alpha | a value that wraps |";
let proposed = new_proposed_plan(table.to_string(), &test_cwd());
assert_eq!(
selectable_text(&proposed, /*width*/ 20, HistoryRenderMode::Rich),
Some("Proposed Plan\n\nName\tValue\nalpha\ta value that wraps".to_string())
);
let tip_source = format!("\n\n{table}");
let tooltip = TooltipHistoryCell::new(tip_source.clone(), &test_cwd());
let expected = crate::markdown_render::render_markdown_selection_text(
&format!("**Tip:** {tip_source}"),
Some(test_cwd().as_path()),
);
assert_eq!(
selectable_text(&tooltip, /*width*/ 20, HistoryRenderMode::Rich),
Some(expected)
);
let reasoning = ReasoningSummaryCell::new(
"Table review".to_string(),
table.to_string(),
&test_cwd(),
/*transcript_only*/ false,
);
assert_eq!(
selectable_text(&reasoning, /*width*/ 20, HistoryRenderMode::Rich),
Some("Name\tValue\nalpha\ta value that wraps".to_string())
);
}
#[test]
fn session_header_selection_excludes_border_and_hidden_directory_text() {
let directory = test_path_buf("/tmp/a-very-long-project-directory").abs();
let cell = SessionHeaderHistoryCell::new(
"gpt-5".to_string(),
Some(ReasoningEffortConfig::High),
/*show_fast_status*/ true,
directory.to_path_buf(),
"test",
);
let text = selectable_text(&cell, /*width*/ 32, HistoryRenderMode::Rich)
.expect("session header should be selectable");
assert!(text.starts_with("OpenAI Codex (vtest)\n\nmodel: gpt-5 high fast /model to change"));
assert!(text.contains("\ndirectory: "));
assert!(text.contains('…'));
assert!(!text.contains('╭'));
}
#[test]
fn patch_history_selection_uses_unwrapped_semantic_rows() {
let patch = new_patch_event(HashMap::<PathBuf, FileChange>::new(), &test_cwd());
assert_eq!(
selectable_text(&patch, /*width*/ 80, HistoryRenderMode::Rich),
Some("Edited 0 files (+0 -0)".to_string())
);
}
#[test]
fn patch_history_selection_does_not_wrap_lines_wider_than_ten_thousand_columns() {
let long_line = "x".repeat(10_050);
let patch = new_patch_event(
HashMap::from([(
PathBuf::from("long.txt"),
FileChange::Add {
content: long_line.clone(),
},
)]),
&test_cwd(),
);
let selected = selectable_text(&patch, /*width*/ 80, HistoryRenderMode::Rich)
.expect("patch should be selectable");
assert!(selected.contains(&long_line));
}
#[test]
fn final_separator_selection_excludes_metrics_hidden_by_width() {
let separator = FinalMessageSeparator::new(
/*elapsed_seconds*/ Some(120),
/*runtime_metrics*/ None,
);
assert_eq!(
selectable_text(&separator, /*width*/ 8, HistoryRenderMode::Rich),
Some("Worked".to_string())
);
}
#[test]
fn structurally_unmapped_stream_cell_is_explicitly_transparent() {
let stream = new_proposed_plan_stream(
vec![HyperlinkLine::from("already rendered")],
/*is_stream_continuation*/ false,
);
assert_eq!(
stream.selection_contribution(/*width*/ 80, HistoryRenderMode::Rich),
SelectionContribution::Transparent
);
}
#[test]
fn source_backed_agent_and_plan_stream_cells_exclude_display_chrome() {
let body_width = 20;
let body_lines = vec![Line::from("alpha beta")];
let fragment = StreamSelectionFragment::new(
CellSelectionProjection::from_display_lines(
"alpha beta".to_string(),
body_lines.clone(),
body_width,
/*first_row_prefix_columns*/ 0,
)
.expect("stream fragment projection"),
body_width,
);
let agent = AgentMessageCell::new_source_backed_hyperlink_lines(
vec![HyperlinkLine::from("alpha beta")],
/*is_first_line*/ true,
Some(fragment.clone()),
);
let agent_tail = StreamingAgentTailCell::new_source_backed(
vec![HyperlinkLine::from("alpha beta")],
/*is_first_line*/ true,
Some(fragment.clone()),
);
let plan_lines = vec![
HyperlinkLine::from("• Proposed Plan"),
HyperlinkLine::from(" "),
HyperlinkLine::from(" "),
HyperlinkLine::from(" alpha beta"),
];
let plan = new_source_backed_proposed_plan_stream(
plan_lines.clone(),
/*is_stream_continuation*/ false,
Some(fragment.clone()),
/*body_line_range*/ 3..4,
);
let plan_tail = StreamingPlanTailCell::new_source_backed(
plan_lines,
/*is_stream_continuation*/ false,
Some(fragment),
/*body_line_range*/ 3..4,
);
assert_eq!(
selectable_text(&agent, /*width*/ 22, HistoryRenderMode::Rich).as_deref(),
Some("alpha beta")
);
assert_eq!(
selectable_text(&agent_tail, /*width*/ 22, HistoryRenderMode::Rich).as_deref(),
Some("alpha beta")
);
for cell in [&plan as &dyn HistoryCell, &plan_tail as &dyn HistoryCell] {
assert_eq!(
selectable_text(cell, /*width*/ 24, HistoryRenderMode::Rich).as_deref(),
Some("Proposed Plan\n\nalpha beta")
);
}
}
+395 -29
View File
@@ -73,6 +73,7 @@ use std::ops::Range;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
use url::Url;
@@ -80,8 +81,9 @@ use url::Url;
mod selection;
mod table_key_value;
pub(crate) use selection::render_markdown_selection_projection;
#[cfg(test)]
pub(crate) use selection::render_markdown_selection_text;
pub(crate) use selection::selection_text_contains_table;
const TABLE_COLUMN_GAP: usize = 2;
const TABLE_CELL_PADDING: usize = 1;
@@ -201,6 +203,20 @@ impl TableCell {
}
buf
}
fn selection_text(&self) -> String {
self.lines
.iter()
.map(|line| {
line.line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
}
/// Accumulates pulldown-cmark table events into a structured representation.
@@ -250,6 +266,150 @@ struct RenderedTableLines {
table_lines: Vec<HyperlinkLine>,
table_lines_prewrapped: bool,
spillover_lines: Vec<HyperlinkLine>,
selection: Option<TableSelectionLayout>,
}
#[derive(Clone, Debug)]
struct TableSelectionLayout {
text: String,
rows: Vec<crate::conversation_selection::SelectionRow>,
source_rows: Vec<Range<usize>>,
}
impl TableSelectionLayout {
fn new(
text: String,
rows: Vec<crate::conversation_selection::SelectionRow>,
source_rows: Vec<Range<usize>>,
) -> Self {
Self {
text,
rows,
source_rows,
}
}
}
#[derive(Clone, Debug)]
struct MarkdownTableSelectionLayout {
text: String,
row_start: usize,
rows: Vec<crate::conversation_selection::SelectionRow>,
}
#[derive(Clone, Debug)]
struct MarkdownTableSourceLayout {
text: String,
row_start: usize,
source_rows: Vec<Range<usize>>,
}
struct RenderedTableRow {
line: HyperlinkLine,
selection: crate::conversation_selection::SelectionRow,
}
struct TableSelectionSource {
text: String,
header: Vec<Range<usize>>,
rows: Vec<Vec<Range<usize>>>,
}
fn table_cell_selection_segments(
source: &str,
wrapped_lines: &[HyperlinkLine],
source_range: Range<usize>,
) -> Vec<Vec<crate::conversation_selection::SelectionSegment>> {
let source_graphemes = source
.grapheme_indices(/*is_extended*/ true)
.map(|(start, grapheme)| (grapheme, start..start + grapheme.len()))
.collect::<Vec<_>>();
let mut source_cursor = 0usize;
wrapped_lines
.iter()
.map(|line| {
let mut segments = Vec::new();
let mut column = 0u16;
for span in &line.line.spans {
for grapheme in span.content.graphemes(/*is_extended*/ true) {
let width = grapheme.width().max(/*other*/ 1).min(usize::from(u16::MAX)) as u16;
let mut source_index = None;
for (index, (candidate, _)) in
source_graphemes.iter().enumerate().skip(source_cursor)
{
if *candidate == grapheme {
source_index = Some(index);
break;
}
if !candidate.chars().all(char::is_whitespace) {
break;
}
}
if let Some(source_index) = source_index {
let local_bytes = &source_graphemes[source_index].1;
segments.push(crate::conversation_selection::SelectionSegment {
columns: column..column.saturating_add(width),
bytes: local_bytes.start.saturating_add(source_range.start)
..local_bytes.end.saturating_add(source_range.start),
});
source_cursor = source_index.saturating_add(/*rhs*/ 1);
}
column = column.saturating_add(width);
}
}
segments
})
.collect()
}
impl TableSelectionSource {
fn new(header: &[TableCell], rows: &[Vec<TableCell>]) -> Self {
let mut text = String::new();
let header = Self::append_row(&mut text, header);
let rows = rows
.iter()
.map(|row| {
text.push('\n');
Self::append_row(&mut text, row)
})
.collect();
Self { text, header, rows }
}
fn append_row(text: &mut String, row: &[TableCell]) -> Vec<Range<usize>> {
let mut ranges = Vec::with_capacity(row.len());
for (column, cell) in row.iter().enumerate() {
if column > 0 {
text.push('\t');
}
let start = text.len();
text.push_str(&cell.selection_text());
ranges.push(start..text.len());
}
ranges
}
fn into_layout(
self,
rows: Vec<crate::conversation_selection::SelectionRow>,
) -> TableSelectionLayout {
let source_rows = std::iter::once(&self.header)
.chain(&self.rows)
.map(|row| {
let start = row.first().map_or(/*default*/ 0, |range| range.start);
let end = row.last().map_or(start, |range| range.end);
start..end
})
.collect();
TableSelectionLayout::new(self.text, rows, source_rows)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TablePresentation {
Display,
Selection,
}
/// Classification of a table column for width-allocation priority.
@@ -328,11 +488,44 @@ pub(crate) fn render_markdown_lines_with_width_and_cwd(
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
let parser = DecodedTextMerge::new(Parser::new_ext(input, options).into_offset_iter());
let mut w = Writer::new(input, parser, width, cwd);
let mut w = Writer::new(input, parser, width, cwd, TablePresentation::Display);
w.run();
w.text
}
fn render_markdown_lines_with_table_selection_and_cwd(
input: &str,
width: Option<usize>,
cwd: Option<&Path>,
) -> (Vec<HyperlinkLine>, Vec<MarkdownTableSelectionLayout>) {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
let parser = DecodedTextMerge::new(Parser::new_ext(input, options).into_offset_iter());
let mut writer = Writer::new(input, parser, width, cwd, TablePresentation::Display);
writer.run();
(writer.text, writer.table_selection_layouts)
}
fn render_markdown_selection_lines_with_cwd(
input: &str,
cwd: Option<&Path>,
) -> (Vec<HyperlinkLine>, Vec<MarkdownTableSourceLayout>) {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
let parser = DecodedTextMerge::new(Parser::new_ext(input, options).into_offset_iter());
let mut writer = Writer::new(
input,
parser,
/*wrap_width*/ None,
cwd,
TablePresentation::Selection,
);
writer.run();
(writer.text, writer.table_source_layouts)
}
#[derive(Clone, Debug)]
struct LinkState {
destination: String,
@@ -399,13 +592,22 @@ where
current_line_style: Style,
current_line_in_code_block: bool,
table_state: Option<TableState>,
table_presentation: TablePresentation,
table_selection_layouts: Vec<MarkdownTableSelectionLayout>,
table_source_layouts: Vec<MarkdownTableSourceLayout>,
}
impl<'a, I> Writer<'a, I>
where
I: Iterator<Item = (Event<'a>, Range<usize>)>,
{
fn new(input: &'a str, iter: I, wrap_width: Option<usize>, cwd: Option<&Path>) -> Self {
fn new(
input: &'a str,
iter: I,
wrap_width: Option<usize>,
cwd: Option<&Path>,
table_presentation: TablePresentation,
) -> Self {
Self {
input,
iter,
@@ -433,6 +635,9 @@ where
current_line_style: Style::default(),
current_line_in_code_block: false,
table_state: None,
table_presentation,
table_selection_layouts: Vec::new(),
table_source_layouts: Vec::new(),
}
}
@@ -886,9 +1091,25 @@ where
table_lines,
table_lines_prewrapped,
spillover_lines,
} = self.render_table_lines(table_state);
mut selection,
} = match self.table_presentation {
TablePresentation::Display => self.render_table_lines(table_state),
TablePresentation::Selection => self.render_table_selection_lines(table_state),
};
let row_start = self.text.len();
let mut pending_marker_line = self.pending_marker_line;
for line in table_lines {
for (row_index, line) in table_lines.into_iter().enumerate() {
if let Some(selection) = selection.as_mut()
&& let Some(row) = selection.rows.get_mut(row_index)
{
let prefix_width =
Self::spans_display_width(&self.prefix_spans(pending_marker_line))
.min(usize::from(u16::MAX)) as u16;
for segment in &mut row.segments {
segment.columns = segment.columns.start.saturating_add(prefix_width)
..segment.columns.end.saturating_add(prefix_width);
}
}
if table_lines_prewrapped {
self.push_prewrapped_line(line, pending_marker_line);
} else {
@@ -897,6 +1118,25 @@ where
}
pending_marker_line = false;
}
if let Some(selection) = selection {
match self.table_presentation {
TablePresentation::Display => {
self.table_selection_layouts
.push(MarkdownTableSelectionLayout {
text: selection.text,
row_start,
rows: selection.rows,
});
}
TablePresentation::Selection => {
self.table_source_layouts.push(MarkdownTableSourceLayout {
text: selection.text,
row_start,
source_rows: selection.source_rows,
});
}
}
}
self.pending_marker_line = false;
for spillover_line in spillover_lines {
self.push_hyperlink_line(spillover_line);
@@ -905,6 +1145,65 @@ where
self.needs_newline = true;
}
/// Render a table as canonical clipboard text.
///
/// Tabs preserve logical cell boundaries, while newlines preserve logical table-row
/// boundaries. Generated padding and separator rules never enter this representation.
fn render_table_selection_lines(&self, table_state: TableState) -> RenderedTableLines {
let column_count = table_state.alignments.len();
if column_count == 0 {
return RenderedTableLines {
table_lines: Vec::new(),
table_lines_prewrapped: true,
spillover_lines: Vec::new(),
selection: None,
};
}
let mut spillover_rows = Vec::with_capacity(/*capacity*/ 4);
let mut rows = Vec::with_capacity(table_state.rows.len());
for (row_index, row) in table_state.rows.iter().enumerate() {
let next_row = table_state.rows.get(row_index.saturating_add(/*rhs*/ 1));
if column_count > 1 && Self::is_spillover_row(row, next_row) {
if let Some(cell) = row.cells.first().cloned() {
spillover_rows.push(cell);
}
} else {
rows.push(row.cells.clone());
}
}
let mut header = table_state
.header
.unwrap_or_else(|| vec![TableCell::default(); column_count]);
Self::normalize_row(&mut header, column_count);
for row in &mut rows {
Self::normalize_row(row, column_count);
}
let selection_source = TableSelectionSource::new(&header, &rows);
let table_lines = std::iter::once(header)
.chain(rows)
.map(|row| {
let text = row
.iter()
.map(TableCell::selection_text)
.collect::<Vec<_>>()
.join("\t");
HyperlinkLine::new(Line::from(text))
})
.collect();
let spillover_lines = spillover_rows
.into_iter()
.flat_map(|spillover| spillover.lines)
.collect();
RenderedTableLines {
table_lines,
table_lines_prewrapped: true,
spillover_lines,
selection: Some(selection_source.into_layout(Vec::new())),
}
}
fn start_table_head(&mut self) {
if let Some(table_state) = self.table_state.as_mut() {
table_state.in_header = true;
@@ -1063,6 +1362,7 @@ where
table_lines: Vec::new(),
table_lines_prewrapped: true,
spillover_lines: Vec::new(),
selection: None,
};
}
@@ -1090,6 +1390,7 @@ where
for row in &mut rows {
Self::normalize_row(row, column_count);
}
let selection_source = TableSelectionSource::new(&header, &rows);
let metrics = Self::collect_table_column_metrics(&header, &rows, column_count);
let available_width = self.available_table_width(column_count);
@@ -1107,17 +1408,26 @@ where
let Some(column_widths) = widths else {
if !rows.is_empty() {
let rendered = table_key_value::render_records(
&header,
&rows,
&metrics,
self.available_record_width(),
header_style,
separator_style,
table_key_value::TableSelectionRanges {
headers: &selection_source.header,
rows: &selection_source.rows,
},
);
return RenderedTableLines {
table_lines: table_key_value::render_records(
&header,
&rows,
&metrics,
self.available_record_width(),
header_style,
separator_style,
),
table_lines: rendered.iter().map(|row| row.line.clone()).collect(),
table_lines_prewrapped: true,
spillover_lines,
selection: Some(
selection_source
.into_layout(rendered.into_iter().map(|row| row.selection).collect()),
),
};
}
return RenderedTableLines {
@@ -1128,55 +1438,77 @@ where
),
table_lines_prewrapped: false,
spillover_lines,
selection: None,
};
};
if table_key_value::should_render_records(&rows, &column_widths, &metrics) {
let rendered = table_key_value::render_records(
&header,
&rows,
&metrics,
self.available_record_width(),
header_style,
separator_style,
table_key_value::TableSelectionRanges {
headers: &selection_source.header,
rows: &selection_source.rows,
},
);
return RenderedTableLines {
table_lines: table_key_value::render_records(
&header,
&rows,
&metrics,
self.available_record_width(),
header_style,
separator_style,
),
table_lines: rendered.iter().map(|row| row.line.clone()).collect(),
table_lines_prewrapped: true,
spillover_lines,
selection: Some(
selection_source
.into_layout(rendered.into_iter().map(|row| row.selection).collect()),
),
};
}
let mut out = Vec::with_capacity(2 + rows.len() * 2);
out.extend(self.render_table_row(
let mut selection_rows = Vec::with_capacity(/*capacity*/ 2 + rows.len() * 2);
for rendered in self.render_table_row(
&header,
&column_widths,
&table_state.alignments,
header_style,
));
&selection_source.header,
) {
out.push(rendered.line);
selection_rows.push(rendered.selection);
}
out.push(Self::render_table_separator(
&column_widths,
TABLE_HEADER_SEPARATOR_CHAR,
separator_style,
));
selection_rows.push(crate::conversation_selection::SelectionRow::default());
for (row_idx, row) in rows.iter().enumerate() {
out.extend(self.render_table_row(
for rendered in self.render_table_row(
row,
&column_widths,
&table_state.alignments,
Style::default(),
));
&selection_source.rows[row_idx],
) {
out.push(rendered.line);
selection_rows.push(rendered.selection);
}
if row_idx + 1 < rows.len() {
out.push(Self::render_table_separator(
&column_widths,
TABLE_BODY_SEPARATOR_CHAR,
separator_style,
));
selection_rows.push(crate::conversation_selection::SelectionRow::default());
}
}
RenderedTableLines {
table_lines: out,
table_lines_prewrapped: true,
spillover_lines,
selection: Some(selection_source.into_layout(selection_rows)),
}
}
@@ -1415,12 +1747,21 @@ where
column_widths: &[usize],
alignments: &[Alignment],
row_style: Style,
) -> Vec<HyperlinkLine> {
selection_ranges: &[Range<usize>],
) -> Vec<RenderedTableRow> {
let wrapped_cells: Vec<Vec<HyperlinkLine>> = row
.iter()
.zip(column_widths)
.map(|(cell, width)| self.wrap_cell(cell, *width))
.collect();
let wrapped_selection = row
.iter()
.zip(&wrapped_cells)
.zip(selection_ranges)
.map(|((cell, lines), range)| {
table_cell_selection_segments(&cell.selection_text(), lines, range.clone())
})
.collect::<Vec<_>>();
let row_height = wrapped_cells.iter().map(Vec::len).max().unwrap_or(1);
let mut out = Vec::with_capacity(row_height);
@@ -1430,7 +1771,10 @@ where
.get(row_line)
.is_some_and(|line| Self::line_display_width(&line.line) > 0)
}) else {
out.push(HyperlinkLine::new(Line::default().style(row_style)));
out.push(RenderedTableRow {
line: HyperlinkLine::new(Line::default().style(row_style)),
selection: crate::conversation_selection::SelectionRow::default(),
});
continue;
};
let mut spans = Vec::new();
@@ -1467,6 +1811,7 @@ where
}
}
let mut out_line = HyperlinkLine::new(Line::from(spans).style(row_style));
let mut selection = crate::conversation_selection::SelectionRow::default();
let mut column_start = 0usize;
for (column, width) in column_widths
.iter()
@@ -1488,10 +1833,25 @@ where
..link.columns.end + column_start + left_padding;
link
}));
let content_start = column_start.saturating_add(left_padding);
let content_start = content_start.min(usize::from(u16::MAX)) as u16;
if let Some(segments) = wrapped_selection[column].get(row_line) {
selection
.segments
.extend(segments.iter().cloned().map(|mut segment| {
segment.columns =
segment.columns.start.saturating_add(content_start)
..segment.columns.end.saturating_add(content_start);
segment
}));
}
}
column_start += *width + TABLE_CELL_PADDING + TABLE_COLUMN_GAP;
}
out.push(out_line);
out.push(RenderedTableRow {
line: out_line,
selection,
});
}
out
}
@@ -2408,7 +2768,13 @@ mod tests {
cell.hard_break();
cell.push_span("second line".into());
let writer = W::new("", std::iter::empty(), Some(80), /*cwd*/ None);
let writer = W::new(
"",
std::iter::empty(),
/*wrap_width*/ Some(80),
/*cwd*/ None,
TablePresentation::Display,
);
let wrapped = writer.wrap_cell(&cell, /*width*/ 40);
let rendered = wrapped
.iter()
+226 -2
View File
@@ -3,33 +3,257 @@
//! This deliberately follows Markdown structure rather than removing characters from rendered
//! terminal rows. Styling delimiters and table chrome never enter the canonical copy string.
use std::ops::Range;
use std::path::Path;
use crate::conversation_selection::CellSelectionProjection;
use crate::conversation_selection::SelectionRow;
#[cfg(test)]
use pulldown_cmark::Event;
#[cfg(test)]
use pulldown_cmark::Options;
#[cfg(test)]
use pulldown_cmark::Parser;
#[cfg(test)]
use pulldown_cmark::Tag;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
#[derive(Clone, Debug)]
struct MarkdownSelectionTableRow {
source: Range<usize>,
document: Range<usize>,
}
#[derive(Clone, Debug)]
struct MarkdownSelectionTable {
text: String,
document: Range<usize>,
rows: Vec<MarkdownSelectionTableRow>,
}
impl MarkdownSelectionTable {
fn map_source_range(&self, source: &Range<usize>) -> Option<Range<usize>> {
let row = self
.rows
.iter()
.find(|row| row.source.start <= source.start && source.end <= row.source.end)?;
let start = row
.document
.start
.checked_add(source.start.checked_sub(row.source.start)?)?;
let end = row
.document
.start
.checked_add(source.end.checked_sub(row.source.start)?)?;
Some(start..end)
}
}
struct MarkdownSelectionDocument {
text: String,
tables: Option<Vec<MarkdownSelectionTable>>,
}
#[cfg(test)]
pub(crate) fn render_markdown_selection_text(input: &str, cwd: Option<&Path>) -> String {
let lines = super::render_markdown_lines_with_width_and_cwd(input, /*width*/ None, cwd);
render_markdown_selection_document(input, cwd).text
}
fn render_markdown_selection_document(
input: &str,
cwd: Option<&Path>,
) -> MarkdownSelectionDocument {
let (lines, table_layouts) = super::render_markdown_selection_lines_with_cwd(input, cwd);
let mut text = String::new();
let mut line_ranges = Vec::with_capacity(lines.len());
for (line_index, line) in lines.into_iter().enumerate() {
if line_index > 0 {
text.push('\n');
}
let start = text.len();
for span in line.line.spans {
text.push_str(&span.content);
}
line_ranges.push(start..text.len());
}
text
let tables = table_layouts
.into_iter()
.map(|layout| -> Option<MarkdownSelectionTable> {
let mut rows = Vec::with_capacity(layout.source_rows.len());
let document_start = line_ranges.get(layout.row_start)?.start;
let mut document_end = document_start;
for (row_offset, source) in layout.source_rows.into_iter().enumerate() {
let line_index = layout.row_start.checked_add(row_offset)?;
let line_range = line_ranges.get(line_index)?.clone();
let line = text.get(line_range.clone())?;
let source_text = layout.text.get(source.clone())?;
let prefix = line.strip_suffix(source_text)?;
let row_start = line_range.start.checked_add(prefix.len())?;
let row_end = row_start.checked_add(source_text.len())?;
rows.push(MarkdownSelectionTableRow {
source,
document: row_start..row_end,
});
document_end = line_range.end;
}
Some(MarkdownSelectionTable {
text: layout.text,
document: document_start..document_end,
rows,
})
})
.collect();
MarkdownSelectionDocument { text, tables }
}
#[cfg(test)]
pub(crate) fn selection_text_contains_table(input: &str) -> bool {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
Parser::new_ext(input, options).any(|event| matches!(event, Event::Start(Tag::Table(_))))
}
/// Builds a source-backed projection for width-aware markdown output.
///
/// Normal markdown uses the shared grapheme aligner. Tables replace those rows with explicit cell
/// mappings while retaining stable logical row/cell order in canonical clipboard text.
pub(crate) fn render_markdown_selection_projection(
input: &str,
markdown_width: usize,
cwd: Option<&Path>,
display_lines: Vec<Line<'static>>,
display_width: u16,
outer_prefix_columns: u16,
) -> Option<CellSelectionProjection> {
if display_lines.is_empty() || display_width == 0 {
return None;
}
let MarkdownSelectionDocument {
text,
tables: source_tables,
} = render_markdown_selection_document(input, cwd);
let (_, table_layouts) =
super::render_markdown_lines_with_table_selection_and_cwd(input, Some(markdown_width), cwd);
if table_layouts.is_empty() {
return CellSelectionProjection::from_display_lines(
text,
display_lines,
display_width,
outer_prefix_columns,
);
}
let Some(source_tables) = source_tables else {
return CellSelectionProjection::from_display_lines(
text,
display_lines,
display_width,
outer_prefix_columns,
);
};
if source_tables.len() != table_layouts.len() {
return CellSelectionProjection::from_display_lines(
text,
display_lines,
display_width,
outer_prefix_columns,
);
}
let mut rows = Vec::new();
let mut text_cursor = 0usize;
let mut display_cursor = 0usize;
for (layout, source_table) in table_layouts.into_iter().zip(source_tables) {
if layout.text != source_table.text
|| source_table.document.start < text_cursor
|| layout.row_start < display_cursor
{
return CellSelectionProjection::from_display_lines(
text,
display_lines,
display_width,
outer_prefix_columns,
);
}
let table_start = source_table.document.start;
let table_row_start = layout.row_start.min(display_lines.len());
let table_row_count = layout.rows.len();
rows.extend(aligned_rows(
&text[text_cursor..table_start],
&display_lines[display_cursor..table_row_start],
display_width,
outer_prefix_columns,
text_cursor,
));
for mut table_row in layout.rows {
for segment in &mut table_row.segments {
let Some(bytes) = source_table.map_source_range(&segment.bytes) else {
return CellSelectionProjection::from_display_lines(
text,
display_lines,
display_width,
outer_prefix_columns,
);
};
segment.columns = segment.columns.start.saturating_add(outer_prefix_columns)
..segment.columns.end.saturating_add(outer_prefix_columns);
segment.bytes = bytes;
}
rows.push(table_row);
}
text_cursor = source_table.document.end;
display_cursor = table_row_start
.saturating_add(table_row_count)
.min(display_lines.len());
}
rows.extend(aligned_rows(
&text[text_cursor..],
&display_lines[display_cursor..],
display_width,
outer_prefix_columns,
text_cursor,
));
CellSelectionProjection::from_rows(text, rows)
}
fn aligned_rows(
text: &str,
display_lines: &[Line<'static>],
display_width: u16,
outer_prefix_columns: u16,
byte_offset: usize,
) -> Vec<SelectionRow> {
let Some(projection) = CellSelectionProjection::from_display_lines(
text.to_string(),
display_lines.to_vec(),
display_width,
outer_prefix_columns,
) else {
return display_lines
.iter()
.flat_map(|line| {
let row_count = Paragraph::new(line.clone())
.wrap(Wrap { trim: false })
.line_count(display_width)
.max(/*other*/ 1);
std::iter::repeat_n(SelectionRow::default(), row_count)
})
.collect();
};
let mut rows = projection.rows().to_vec();
for row in &mut rows {
for segment in &mut row.segments {
segment.bytes = segment.bytes.start.saturating_add(byte_offset)
..segment.bytes.end.saturating_add(byte_offset);
}
}
rows
}
#[cfg(test)]
#[path = "selection_tests.rs"]
mod tests;
@@ -1,5 +1,6 @@
use pretty_assertions::assert_eq;
use super::render_markdown_selection_projection;
use super::render_markdown_selection_text;
#[test]
@@ -152,3 +153,70 @@ fn agent_markdown_table_fence_uses_the_same_normalization_as_display() {
);
assert!(super::selection_text_contains_table(&normalized));
}
#[test]
fn table_selection_uses_logical_cell_and_row_separators() {
assert_eq!(
render_markdown_selection_text(
"| Name | Value |\n| --- | --- |\n| alpha | one |\n| beta | two |\n",
/*cwd*/ None,
),
"Name\tValue\nalpha\tone\nbeta\ttwo"
);
}
#[test]
fn table_selection_preserves_rendered_inline_semantics_without_chrome() {
assert_eq!(
render_markdown_selection_text(
"| Kind | Target |\n| --- | --- |\n| **docs** | [guide](https://example.com/guide) |\n",
/*cwd*/ None,
),
"Kind\tTarget\ndocs\tguide (https://example.com/guide)"
);
}
#[test]
fn table_projection_uses_structural_occurrences_instead_of_matching_cell_text() {
let canonical = "A\tB\n1\t2";
let table = "| A | B |\n| --- | --- |\n| 1 | 2 |";
let markdown = format!("```text\n{canonical}\n```\n\n{table}\n\n{table}");
let width = 48usize;
let display_width = u16::try_from(width).expect("fixture width should fit in u16");
let display_lines = super::super::render_markdown_lines_with_width_and_cwd(
&markdown,
Some(width),
/*cwd*/ None,
)
.into_iter()
.map(|line| line.line)
.collect();
let projection = render_markdown_selection_projection(
&markdown,
width,
/*cwd*/ None,
display_lines,
display_width,
/*outer_prefix_columns*/ 0,
)
.expect("markdown containing tables should expose a selection projection");
let occurrence_starts = projection
.text()
.match_indices(canonical)
.map(|(start, _)| start)
.collect::<Vec<_>>();
assert_eq!(occurrence_starts.len(), 3);
let mapped_starts = projection
.rows()
.iter()
.flat_map(|row| row.segments.iter())
.map(|segment| segment.bytes.start)
.collect::<std::collections::BTreeSet<_>>();
assert!(
occurrence_starts[1..]
.iter()
.all(|start| mapped_starts.contains(start)),
"each repeated table should map to its own structural source occurrence"
);
}
@@ -1,9 +1,15 @@
//! Vertical key/value rendering for markdown tables that no longer scan well as grids.
use std::ops::Range;
use super::RenderedTableRow;
use super::TABLE_BODY_SEPARATOR_CHAR;
use super::TableCell;
use super::TableColumnKind;
use super::TableColumnMetrics;
use super::table_cell_selection_segments;
use crate::conversation_selection::SelectionRow;
use crate::conversation_selection::SelectionSegment;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::push_owned_lines;
use crate::terminal_hyperlinks::HyperlinkLine;
@@ -26,6 +32,16 @@ const CRAMPED_EXPANSIVE_CELL_LINES: usize = 4;
const CATASTROPHIC_NARRATIVE_CELL_LINES: usize = 7;
const STACKED_VALUE_INDENT: usize = 2;
pub(super) struct TableSelectionRanges<'a> {
pub(super) headers: &'a [Range<usize>],
pub(super) rows: &'a [Vec<Range<usize>>],
}
struct FieldSelectionRanges {
header: Range<usize>,
value: Range<usize>,
}
/// Switch modes after enough records contain values the grid can no longer
/// present in useful chunks or expansive content collapses into tall strips.
pub(super) fn should_render_records(
@@ -102,7 +118,8 @@ pub(super) fn render_records(
available_width: Option<usize>,
label_style: Style,
separator_style: Style,
) -> Vec<HyperlinkLine> {
selection_ranges: TableSelectionRanges<'_>,
) -> Vec<RenderedTableRow> {
let label_width = headers
.iter()
.map(|header| header.plain_text().width())
@@ -122,7 +139,11 @@ pub(super) fn render_records(
let mut out = Vec::new();
for (row_index, row) in rows.iter().enumerate() {
for (header, value) in headers.iter().zip(row) {
for (column, (header, value)) in headers.iter().zip(row).enumerate() {
let field_selection_ranges = FieldSelectionRanges {
header: selection_ranges.headers[column].clone(),
value: selection_ranges.rows[row_index][column].clone(),
};
if aligned_fields {
render_aligned_field(
&mut out,
@@ -131,17 +152,28 @@ pub(super) fn render_records(
label_width,
available_width,
label_style,
field_selection_ranges,
);
} else {
render_stacked_field(&mut out, header, value, available_width, label_style);
render_stacked_field(
&mut out,
header,
value,
available_width,
label_style,
field_selection_ranges,
);
}
}
if row_index + 1 < rows.len() {
let width = available_width.unwrap_or_else(|| widest_line_width(&out));
out.push(HyperlinkLine::new(Line::from(Span::styled(
TABLE_BODY_SEPARATOR_CHAR.to_string().repeat(width),
separator_style,
))));
out.push(RenderedTableRow {
line: HyperlinkLine::new(Line::from(Span::styled(
TABLE_BODY_SEPARATOR_CHAR.to_string().repeat(width),
separator_style,
))),
selection: SelectionRow::default(),
});
}
}
@@ -149,40 +181,67 @@ pub(super) fn render_records(
}
fn render_aligned_field(
out: &mut Vec<HyperlinkLine>,
out: &mut Vec<RenderedTableRow>,
header: &TableCell,
value: &TableCell,
label_width: usize,
available_width: Option<usize>,
label_style: Style,
selection_ranges: FieldSelectionRanges,
) {
let value_indent = FIELD_LEADING_PADDING + label_width + FIELD_GAP;
let value_width = available_width
.map(|width| width.saturating_sub(value_indent).max(MIN_VALUE_WIDTH))
.unwrap_or_else(|| cell_width(value).max(MIN_VALUE_WIDTH));
let wrapped_value = wrap_cell(value, value_width);
let wrapped_value_selection = table_cell_selection_segments(
&value.selection_text(),
&wrapped_value,
selection_ranges.value,
);
for (line_index, value_line) in wrapped_value.into_iter().enumerate() {
let mut spans = Vec::new();
let mut selection = SelectionRow::default();
if line_index == 0 {
let label = header.plain_text();
let label_line = HyperlinkLine::new(Line::from(label.clone()));
let label_selection = table_cell_selection_segments(
&header.selection_text(),
std::slice::from_ref(&label_line),
selection_ranges.header.clone(),
);
spans.push(Span::raw(" ".repeat(FIELD_LEADING_PADDING)));
spans.push(Span::styled(label.clone(), label_style));
spans.push(Span::raw(
" ".repeat(label_width.saturating_sub(label.width()) + FIELD_GAP),
));
if let Some(label_selection) = label_selection.first() {
selection
.segments
.extend(shift_segments(label_selection, FIELD_LEADING_PADDING));
}
} else {
spans.push(Span::raw(" ".repeat(value_indent)));
}
push_prefixed_value_line(out, spans, value_line);
if let Some(value_selection) = wrapped_value_selection.get(line_index) {
selection
.segments
.extend(shift_segments(value_selection, value_indent));
}
out.push(RenderedTableRow {
line: prefixed_value_line(spans, value_line),
selection,
});
}
}
fn render_stacked_field(
out: &mut Vec<HyperlinkLine>,
out: &mut Vec<RenderedTableRow>,
header: &TableCell,
value: &TableCell,
available_width: Option<usize>,
label_style: Style,
selection_ranges: FieldSelectionRanges,
) {
let label_width = available_width
.map(|width| width.saturating_sub(FIELD_LEADING_PADDING).max(1))
@@ -193,29 +252,59 @@ fn render_stacked_field(
&word_wrap_line(&label, RtOptions::new(label_width)),
&mut wrapped_labels,
);
for label_line in wrapped_labels {
let label_hyperlink_lines = wrapped_labels
.iter()
.cloned()
.map(HyperlinkLine::new)
.collect::<Vec<_>>();
let wrapped_label_selection = table_cell_selection_segments(
&header.plain_text(),
&label_hyperlink_lines,
selection_ranges.header,
);
for (line_index, label_line) in wrapped_labels.into_iter().enumerate() {
let mut spans = vec![Span::raw(" ".repeat(FIELD_LEADING_PADDING))];
spans.extend(label_line.spans);
out.push(HyperlinkLine::new(Line::from(spans)));
out.push(RenderedTableRow {
line: HyperlinkLine::new(Line::from(spans)),
selection: SelectionRow {
segments: wrapped_label_selection
.get(line_index)
.map(|segments| shift_segments(segments, FIELD_LEADING_PADDING))
.unwrap_or_default(),
},
});
}
let value_width = available_width
.map(|width| width.saturating_sub(STACKED_VALUE_INDENT).max(1))
.unwrap_or_else(|| cell_width(value).max(1));
for value_line in wrap_cell(value, value_width) {
push_prefixed_value_line(
out,
vec![Span::raw(" ".repeat(STACKED_VALUE_INDENT))],
value_line,
);
let wrapped_value = wrap_cell(value, value_width);
let wrapped_value_selection = table_cell_selection_segments(
&value.selection_text(),
&wrapped_value,
selection_ranges.value,
);
for (line_index, value_line) in wrapped_value.into_iter().enumerate() {
out.push(RenderedTableRow {
line: prefixed_value_line(
vec![Span::raw(" ".repeat(STACKED_VALUE_INDENT))],
value_line,
),
selection: SelectionRow {
segments: wrapped_value_selection
.get(line_index)
.map(|segments| shift_segments(segments, STACKED_VALUE_INDENT))
.unwrap_or_default(),
},
});
}
}
fn push_prefixed_value_line(
out: &mut Vec<HyperlinkLine>,
fn prefixed_value_line(
mut prefix: Vec<Span<'static>>,
mut value_line: HyperlinkLine,
) {
) -> HyperlinkLine {
let shift = prefix
.iter()
.map(|span| span.content.width())
@@ -228,7 +317,20 @@ fn push_prefixed_value_line(
link.columns = link.columns.start + shift..link.columns.end + shift;
link
}));
out.push(output_line);
output_line
}
fn shift_segments(segments: &[SelectionSegment], shift: usize) -> Vec<SelectionSegment> {
let shift = shift.min(usize::from(u16::MAX)) as u16;
segments
.iter()
.cloned()
.map(|mut segment| {
segment.columns = segment.columns.start.saturating_add(shift)
..segment.columns.end.saturating_add(shift);
segment
})
.collect()
}
fn wrap_cell(cell: &TableCell, width: usize) -> Vec<HyperlinkLine> {
@@ -268,11 +370,12 @@ fn cell_width(cell: &TableCell) -> usize {
.unwrap_or(0)
}
fn widest_line_width(lines: &[HyperlinkLine]) -> usize {
fn widest_line_width(lines: &[RenderedTableRow]) -> usize {
lines
.iter()
.map(|line| {
line.line
.line
.spans
.iter()
.map(|span| span.content.width())
+39 -1
View File
@@ -454,11 +454,36 @@ fn resume_end(
}
fn collab_event(title: Line<'static>, details: Vec<Line<'static>>) -> PlainHistoryCell {
let title_text = title
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
let title_text = title_text.strip_prefix("• ").unwrap_or(&title_text);
let mut selection_text = title_text.to_string();
let mut prefix_columns = vec![2];
if !details.is_empty() {
selection_text.push('\n');
selection_text.push_str(
&details
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n"),
);
prefix_columns.extend(std::iter::repeat_n(/*element*/ 4, details.len()));
}
let mut lines: Vec<Line<'static>> = vec![title];
if !details.is_empty() {
lines.extend(prefix_lines(details, " └ ".dim(), " ".into()));
}
PlainHistoryCell::new(lines)
PlainHistoryCell::new(lines).with_selection_text(selection_text, prefix_columns)
}
fn title_text(title: impl Into<String>) -> Line<'static> {
@@ -786,6 +811,19 @@ mod tests {
)
.expect("close item renders");
let selected = spawn
.selection_contribution(
/*width*/ 24,
crate::history_cell::HistoryRenderMode::Rich,
)
.into_projection()
.expect("collaboration event should be selectable")
.text()
.to_string();
assert!(selected.contains("Compute 11! and reply with just the integer result."));
assert!(!selected.contains('•'));
assert!(!selected.contains(" └ "));
let snapshot = [spawn, send, waiting, finished, close]
.iter()
.map(cell_to_text)
+11
View File
@@ -1132,6 +1132,17 @@ mod tests {
self.lines.clone()
}
fn selection_contribution(
&self,
width: u16,
mode: crate::history_cell::HistoryRenderMode,
) -> crate::history_cell::SelectionContribution {
crate::history_cell::selection_contribution_from_display_lines(
self.display_lines_for_mode(width, mode),
width,
)
}
fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
self.lines.clone()
}
+32
View File
@@ -1,7 +1,11 @@
use crate::history_cell::CompositeHistoryCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::SelectionContribution;
use crate::history_cell::plain_lines;
use crate::history_cell::selection_contribution_from_semantic_text;
use crate::history_cell::selection_text_from_lines;
use crate::history_cell::with_border_with_inner_width;
use crate::legacy_core::config::Config;
use crate::token_usage::TokenUsage;
@@ -877,6 +881,34 @@ impl HistoryCell for StatusHistoryCell {
plain_lines(self.display_lines(u16::MAX))
}
fn selection_contribution(&self, width: u16, mode: HistoryRenderMode) -> SelectionContribution {
let lines = self.display_lines_for_mode(width, mode);
let inner_lines = lines
.iter()
.skip(/*n*/ 1)
.take(lines.len().saturating_sub(/*rhs*/ 2))
.map(|line| {
let visible = selection_text_from_lines(std::slice::from_ref(line));
let content = visible
.strip_prefix("│ ")
.and_then(|content| content.strip_suffix(" │"))
.unwrap_or(&visible)
.trim_end();
content
.trim_start()
.strip_prefix(">_ ")
.unwrap_or(content.trim_start())
.to_string()
})
.collect::<Vec<_>>();
selection_contribution_from_semantic_text(
inner_lines.join("\n"),
lines,
width,
/*first_row_prefix_columns*/ 2,
)
}
fn display_hyperlink_lines(
&self,
width: u16,
+264 -25
View File
@@ -35,8 +35,10 @@
//! - During confirmed table streaming, only lines from the table header onward
//! are forced into tail; pre-table lines may remain stable.
use crate::conversation_selection::CellSelectionProjection;
use crate::history_cell::HistoryCell;
use crate::history_cell::HistoryRenderMode;
use crate::history_cell::StreamSelectionFragment;
use crate::history_cell::raw_lines_from_source;
use crate::history_cell::{self};
use crate::markdown::render_markdown_agent_with_links_and_cwd;
@@ -44,10 +46,15 @@ use crate::style::proposed_plan_style;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::plain_hyperlink_lines;
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
use crate::terminal_hyperlinks::visible_lines;
use ratatui::prelude::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::ops::Range;
use std::path::Path;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::time::Duration;
use std::time::Instant;
@@ -85,6 +92,8 @@ struct StreamCore {
/// Session cwd used to keep local file-link display stable during stream re-renders.
cwd: PathBuf,
render_mode: HistoryRenderMode,
/// Lazily built source mapping for the current raw-source/render snapshot.
selection_projection_cache: OnceLock<Option<StreamSelectionProjection>>,
/// Cached rendered line count for prefix-before-table keyed by source start and width.
stable_prefix_len_cache: Option<StablePrefixLenCache>,
/// Incremental holdback scanner state for append-only source updates.
@@ -103,6 +112,13 @@ struct StablePrefixLenCache {
stable_prefix_len: usize,
}
struct StreamSelectionProjection {
projection: CellSelectionProjection,
/// Terminal-row offsets for every boundary in `rendered_lines`.
row_offsets: Vec<usize>,
body_width: u16,
}
impl StreamCore {
fn new(width: Option<usize>, cwd: &Path, render_mode: HistoryRenderMode) -> Self {
Self {
@@ -114,6 +130,7 @@ impl StreamCore {
emitted_stable_len: 0,
cwd: cwd.to_path_buf(),
render_mode,
selection_projection_cache: OnceLock::new(),
stable_prefix_len_cache: None,
holdback_scanner: TableHoldbackScanner::new(),
}
@@ -157,11 +174,12 @@ impl StreamCore {
self.raw_source.push_str(&remainder_source);
self.holdback_scanner.push_source_chunk(&remainder_source);
}
let rendered = self.render_source(&self.raw_source);
if self.emitted_stable_len >= rendered.len() {
self.rendered_lines = self.render_source(&self.raw_source);
self.selection_projection_cache.take();
if self.emitted_stable_len >= self.rendered_lines.len() {
Vec::new()
} else {
rendered[self.emitted_stable_len..].to_vec()
self.rendered_lines[self.emitted_stable_len..].to_vec()
}
}
@@ -216,6 +234,25 @@ impl StreamCore {
self.rendered_lines[start..].to_vec()
}
fn selection_fragment(&self, rendered_range: Range<usize>) -> Option<StreamSelectionFragment> {
let selection = self
.selection_projection_cache
.get_or_init(|| self.build_selection_projection())
.as_ref()?;
let logical_start = rendered_range.start.min(self.rendered_lines.len());
let logical_end = rendered_range.end.min(self.rendered_lines.len());
let row_start = *selection.row_offsets.get(logical_start)?;
let row_end = *selection.row_offsets.get(logical_end)?;
selection
.projection
.slice_rows(row_start..row_end)
.map(|projection| StreamSelectionFragment::new(projection, selection.body_width))
}
fn current_tail_selection_fragment(&self) -> Option<StreamSelectionFragment> {
self.selection_fragment(self.enqueued_stable_len..self.rendered_lines.len())
}
#[inline]
fn has_tail(&self) -> bool {
self.enqueued_stable_len < self.rendered_lines.len()
@@ -271,6 +308,7 @@ impl StreamCore {
self.rendered_lines.clear();
self.enqueued_stable_len = 0;
self.emitted_stable_len = 0;
self.selection_projection_cache.take();
self.stable_prefix_len_cache = None;
self.holdback_scanner.reset();
}
@@ -286,8 +324,69 @@ impl StreamCore {
}
}
fn build_selection_projection(&self) -> Option<StreamSelectionProjection> {
if self.raw_source.is_empty() || self.rendered_lines.is_empty() {
return None;
}
let body_width = self
.width
.and_then(|width| u16::try_from(width).ok())
.unwrap_or(u16::MAX)
.max(/*other*/ 1);
let display_lines = visible_lines(self.rendered_lines.clone());
let projection = match self.render_mode {
HistoryRenderMode::Rich => {
let normalized = crate::markdown::unwrap_markdown_fences(&self.raw_source);
crate::markdown_render::render_markdown_selection_projection(
&normalized,
self.width.unwrap_or(usize::from(body_width)),
Some(self.cwd.as_path()),
display_lines.clone(),
body_width,
/*outer_prefix_columns*/ 0,
)?
}
HistoryRenderMode::Raw => CellSelectionProjection::from_display_lines(
self.raw_source.clone(),
display_lines.clone(),
body_width,
/*first_row_prefix_columns*/ 0,
)?,
};
let mut row_offsets: Vec<usize> =
Vec::with_capacity(display_lines.len().saturating_add(/*rhs*/ 1));
row_offsets.push(/*value*/ 0);
for line in display_lines {
let row_count = Paragraph::new(line)
.wrap(Wrap { trim: false })
.line_count(body_width)
.max(/*other*/ 1);
let next = row_offsets
.last()
.copied()
.unwrap_or_default()
.saturating_add(row_count);
row_offsets.push(next);
}
if row_offsets.last().copied() != Some(projection.rows().len()) {
tracing::debug!(
rendered_lines = self.rendered_lines.len(),
projected_rows = projection.rows().len(),
mapped_rows = row_offsets.last().copied().unwrap_or_default(),
"stream selection projection row mapping diverged",
);
return None;
}
Some(StreamSelectionProjection {
projection,
row_offsets,
body_width,
})
}
fn recompute_streaming_render(&mut self) {
self.rendered_lines = self.render_source(&self.raw_source);
self.selection_projection_cache.take();
}
fn set_render_mode(&mut self, render_mode: HistoryRenderMode) {
@@ -484,30 +583,42 @@ impl StreamController {
/// Finalize the active stream. Returns the final cell (if any remaining lines) and the raw
/// markdown source for consolidation.
pub(crate) fn finalize(&mut self) -> (Option<Box<dyn HistoryCell>>, Option<String>) {
let rendered_start = self.core.emitted_stable_len;
let remaining = self.core.finalize_remaining();
if self.core.raw_source.is_empty() {
self.core.reset();
return (None, None);
}
let selection_fragment = self
.core
.selection_fragment(rendered_start..self.core.rendered_lines.len());
// Move ownership — source is consumed before reset() clears it.
let source = std::mem::take(&mut self.core.raw_source);
let out = self.emit(remaining);
let out = self.emit(remaining, selection_fragment);
self.core.reset();
(out, Some(source))
}
pub(crate) fn on_commit_tick(&mut self) -> (Option<Box<dyn HistoryCell>>, bool) {
let rendered_start = self.core.emitted_stable_len;
let step = self.core.tick();
(self.emit(step), self.core.is_idle())
let selection_fragment = self
.core
.selection_fragment(rendered_start..self.core.emitted_stable_len);
(self.emit(step, selection_fragment), self.core.is_idle())
}
pub(crate) fn on_commit_tick_batch(
&mut self,
max_lines: usize,
) -> (Option<Box<dyn HistoryCell>>, bool) {
let rendered_start = self.core.emitted_stable_len;
let step = self.core.tick_batch(max_lines);
(self.emit(step), self.core.is_idle())
let selection_fragment = self
.core
.selection_fragment(rendered_start..self.core.emitted_stable_len);
(self.emit(step, selection_fragment), self.core.is_idle())
}
// Thin StreamController accessors inlined — one-liner delegates called
@@ -527,6 +638,11 @@ impl StreamController {
self.core.current_tail_lines()
}
#[inline]
pub(crate) fn current_tail_selection_fragment(&self) -> Option<StreamSelectionFragment> {
self.core.current_tail_selection_fragment()
}
#[inline]
pub(crate) fn tail_starts_stream(&self) -> bool {
!self.header_emitted && self.core.enqueued_stable_len == 0
@@ -550,16 +666,24 @@ impl StreamController {
self.core.set_render_mode(render_mode);
}
fn emit(&mut self, lines: Vec<HyperlinkLine>) -> Option<Box<dyn HistoryCell>> {
fn emit(
&mut self,
lines: Vec<HyperlinkLine>,
selection_fragment: Option<StreamSelectionFragment>,
) -> Option<Box<dyn HistoryCell>> {
if lines.is_empty() {
return None;
}
Some(Box::new(
history_cell::AgentMessageCell::new_hyperlink_lines(lines, {
let header_emitted = self.header_emitted;
self.header_emitted = true;
!header_emitted
}),
history_cell::AgentMessageCell::new_source_backed_hyperlink_lines(
lines,
{
let header_emitted = self.header_emitted;
self.header_emitted = true;
!header_emitted
},
selection_fragment,
),
))
}
}
@@ -577,6 +701,17 @@ pub(crate) struct PlanStreamController {
top_padding_emitted: bool,
}
pub(crate) struct PlanStreamTailDisplay {
pub(crate) lines: Vec<HyperlinkLine>,
pub(crate) body_line_range: Range<usize>,
pub(crate) selection_fragment: Option<StreamSelectionFragment>,
}
struct RenderedPlanStreamLines {
lines: Vec<HyperlinkLine>,
body_line_range: Range<usize>,
}
impl PlanStreamController {
/// Create a plan-stream controller whose markdown renderer shortens local file links relative
/// to `cwd`.
@@ -598,23 +733,39 @@ impl PlanStreamController {
/// Finalize the active stream. Returns the final cell (if any remaining
/// lines) plus raw markdown source for consolidation.
pub(crate) fn finalize(&mut self) -> (Option<Box<dyn HistoryCell>>, Option<String>) {
let rendered_start = self.core.emitted_stable_len;
let remaining = self.core.finalize_remaining();
if self.core.raw_source.is_empty() {
self.core.reset();
return (None, None);
}
let selection_fragment = self
.core
.selection_fragment(rendered_start..self.core.rendered_lines.len());
// Move ownership — source is consumed before reset() clears it.
let source = std::mem::take(&mut self.core.raw_source);
let out = self.emit(remaining, /*include_bottom_padding*/ true);
let out = self.emit(
remaining,
selection_fragment,
/*include_bottom_padding*/ true,
);
self.core.reset();
(out, Some(source))
}
pub(crate) fn on_commit_tick(&mut self) -> (Option<Box<dyn HistoryCell>>, bool) {
let rendered_start = self.core.emitted_stable_len;
let step = self.core.tick();
let selection_fragment = self
.core
.selection_fragment(rendered_start..self.core.emitted_stable_len);
(
self.emit(step, /*include_bottom_padding*/ false),
self.emit(
step,
selection_fragment,
/*include_bottom_padding*/ false,
),
self.core.is_idle(),
)
}
@@ -623,9 +774,17 @@ impl PlanStreamController {
&mut self,
max_lines: usize,
) -> (Option<Box<dyn HistoryCell>>, bool) {
let rendered_start = self.core.emitted_stable_len;
let step = self.core.tick_batch(max_lines);
let selection_fragment = self
.core
.selection_fragment(rendered_start..self.core.emitted_stable_len);
(
self.emit(step, /*include_bottom_padding*/ false),
self.emit(
step,
selection_fragment,
/*include_bottom_padding*/ false,
),
self.core.is_idle(),
)
}
@@ -650,12 +809,17 @@ impl PlanStreamController {
!self.header_emitted && self.core.enqueued_stable_len == 0
}
pub(crate) fn current_tail_display_lines(&self) -> Vec<HyperlinkLine> {
pub(crate) fn current_tail_display(&self) -> Option<PlanStreamTailDisplay> {
let lines = self.current_tail_lines();
if lines.is_empty() {
return Vec::new();
return None;
}
self.render_display_lines(lines, /*include_bottom_padding*/ false)
let rendered = self.render_display_lines(lines, /*include_bottom_padding*/ false);
Some(PlanStreamTailDisplay {
lines: rendered.lines,
body_line_range: rendered.body_line_range,
selection_fragment: self.core.current_tail_selection_fragment(),
})
}
pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option<Duration> {
@@ -678,6 +842,7 @@ impl PlanStreamController {
fn emit(
&mut self,
lines: Vec<HyperlinkLine>,
selection_fragment: Option<StreamSelectionFragment>,
include_bottom_padding: bool,
) -> Option<Box<dyn HistoryCell>> {
if lines.is_empty() && !include_bottom_padding {
@@ -685,21 +850,25 @@ impl PlanStreamController {
}
let is_stream_continuation = self.header_emitted;
let out_lines = self.render_display_lines(lines, include_bottom_padding);
let rendered = self.render_display_lines(lines, include_bottom_padding);
self.header_emitted = true;
self.top_padding_emitted = true;
Some(Box::new(history_cell::new_proposed_plan_stream(
out_lines,
is_stream_continuation,
)))
Some(Box::new(
history_cell::new_source_backed_proposed_plan_stream(
rendered.lines,
is_stream_continuation,
selection_fragment,
rendered.body_line_range,
),
))
}
fn render_display_lines(
&self,
lines: Vec<HyperlinkLine>,
include_bottom_padding: bool,
) -> Vec<HyperlinkLine> {
) -> RenderedPlanStreamLines {
let mut out_lines: Vec<HyperlinkLine> = Vec::with_capacity(/*capacity*/ 4);
if !self.header_emitted {
out_lines.push(HyperlinkLine::new(
@@ -712,6 +881,8 @@ impl PlanStreamController {
if !self.top_padding_emitted {
plan_lines.push(HyperlinkLine::new(Line::from(" ")));
}
let body_line_start = out_lines.len().saturating_add(plan_lines.len());
let body_line_end = body_line_start.saturating_add(lines.len());
plan_lines.extend(lines);
if include_bottom_padding {
plan_lines.push(HyperlinkLine::new(Line::from(" ")));
@@ -723,7 +894,10 @@ impl PlanStreamController {
.map(|line| line.style(plan_style))
.collect::<Vec<_>>();
out_lines.extend(plan_lines);
out_lines
RenderedPlanStreamLines {
lines: out_lines,
body_line_range: body_line_start..body_line_end,
}
}
}
@@ -765,6 +939,12 @@ mod tests {
lines_to_plain_strings(&visible_lines(lines.to_vec()))
}
fn selection_text(cell: &dyn HistoryCell, width: u16) -> Option<String> {
cell.selection_contribution(width, HistoryRenderMode::Rich)
.into_projection()
.map(|projection| projection.text().to_string())
}
fn collect_streamed_lines(deltas: &[&str], width: Option<usize>) -> Vec<String> {
let mut ctrl = stream_controller(width);
let mut lines = Vec::new();
@@ -913,6 +1093,65 @@ mod tests {
assert!(!ctrl.has_live_tail());
}
#[test]
fn emitted_selection_fragments_preserve_soft_wraps_and_authored_newlines() {
let collect = |source: &str| {
let mut ctrl = stream_controller(/*width*/ Some(12));
assert!(ctrl.push(source));
let mut fragments = Vec::new();
loop {
let (cell, idle) = ctrl.on_commit_tick();
if let Some(cell) = cell {
fragments.push(
selection_text(cell.as_ref(), /*width*/ 14)
.expect("emitted stream cell should be selectable"),
);
}
if idle {
break;
}
}
fragments
};
let soft_wrapped = collect("alpha beta gamma delta epsilon\n");
assert!(soft_wrapped.len() > 1);
assert_eq!(soft_wrapped.concat(), "alpha beta gamma delta epsilon");
let hard_wrapped = collect("alpha\nbeta\n");
assert_eq!(hard_wrapped.concat(), "alpha\nbeta");
}
#[test]
fn agent_and_plan_table_tails_keep_explicit_semantic_projection() {
let source = "| A | B |\n| --- | --- |\n| one | two |\n";
let mut agent = stream_controller(/*width*/ Some(40));
agent.push(source);
let agent_tail = history_cell::StreamingAgentTailCell::new_source_backed(
agent.current_tail_lines(),
agent.tail_starts_stream(),
agent.current_tail_selection_fragment(),
);
assert_eq!(
selection_text(&agent_tail, /*width*/ 42).as_deref(),
Some("A\tB\none\ttwo")
);
let mut plan = plan_stream_controller(/*width*/ Some(40));
plan.push(source);
let tail = plan.current_tail_display().expect("plan table tail");
let plan_tail = history_cell::StreamingPlanTailCell::new_source_backed(
tail.lines,
!plan.tail_starts_stream(),
tail.selection_fragment,
tail.body_line_range,
);
assert_eq!(
selection_text(&plan_tail, /*width*/ 44).as_deref(),
Some("Proposed Plan\n\nA\tB\none\ttwo")
);
}
#[test]
fn controller_live_tail_rerenders_table_tail_after_resize() {
let mut ctrl = stream_controller(Some(96));
+30 -17
View File
@@ -139,28 +139,37 @@ fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
exit_code,
..
} => {
let mut lines: Vec<Line<'static>> =
vec![vec!["$ ".dim(), command.clone().into()].into()];
lines.push(
format!(
"status: {status:?}{}",
exit_code
.map(|code| format!(" · exit {code}"))
.unwrap_or_default()
)
.dim()
.into(),
let mut lines = Vec::<Line<'static>>::new();
let mut selection_lines = Vec::new();
let mut prefix_columns = Vec::new();
for (index, command_line) in command.split('\n').enumerate() {
let prefix = if index == 0 { "$ " } else { " " };
lines.push(vec![prefix.dim(), command_line.to_string().into()].into());
selection_lines.push(command_line.to_string());
prefix_columns.push(2);
}
let status_line = format!(
"status: {status:?}{}",
exit_code
.map(|code| format!(" · exit {code}"))
.unwrap_or_default()
);
lines.push(status_line.clone().dim().into());
selection_lines.push(status_line);
prefix_columns.push(0);
if let Some(output) = aggregated_output.as_deref()
&& !output.trim().is_empty()
{
lines.extend(
output
.lines()
.map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()),
);
for output_line in output.lines() {
lines.push(vec![" ".dim(), output_line.to_string().dim()].into());
selection_lines.push(output_line.to_string());
prefix_columns.push(2);
}
}
lines
return Some(
PlainHistoryCell::new(lines)
.with_selection_text(selection_lines.join("\n"), prefix_columns),
);
}
ThreadItem::FileChange {
changes, status, ..
@@ -232,3 +241,7 @@ fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
};
(!lines.is_empty()).then(|| PlainHistoryCell::new(lines))
}
#[cfg(test)]
#[path = "thread_transcript_tests.rs"]
mod tests;
@@ -0,0 +1,52 @@
use super::*;
use crate::history_cell::HistoryRenderMode;
use crate::history_cell::SelectionContribution;
use codex_app_server_protocol::CommandExecutionSource;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
#[test]
fn fallback_command_selection_excludes_gutters_and_preserves_output_whitespace() {
let item = ThreadItem::CommandExecution {
id: "command-1".to_string(),
command: "printf 'x'\nprintf 'y'".to_string(),
cwd: AbsolutePathBuf::try_from("/workspace")
.expect("absolute path")
.into(),
process_id: None,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::Completed,
command_actions: Vec::new(),
aggregated_output: Some("first \n\nthird \n".to_string()),
exit_code: Some(0),
duration_ms: Some(1),
};
let cell = fallback_transcript_cell(&item).expect("fallback command cell");
let rendered = cell
.display_lines(/*width*/ 80)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
insta::assert_debug_snapshot!(rendered, @r###"
[
"$ printf 'x'",
" printf 'y'",
"status: Completed · exit 0",
" first ",
" ",
" third ",
]
"###);
let SelectionContribution::Selectable(projection) =
cell.selection_contribution(/*width*/ 80, HistoryRenderMode::Rich)
else {
panic!("fallback command should be selectable");
};
assert_eq!(
projection.text(),
"printf 'x'\nprintf 'y'\nstatus: Completed · exit 0\nfirst \n\nthird "
);
}