mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
feat(tui): expand owned-screen text selection
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]),
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
mod model;
|
||||
mod render;
|
||||
mod selection;
|
||||
|
||||
pub(crate) use model::CommandOutput;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('•'));
|
||||
}
|
||||
|
||||
@@ -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())])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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.
|
||||
///
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 "
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user