fix(tui): allow selection across all transcript cell types

The selection-tightening from 7125172f restricted copy/select to user
and assistant cell bodies only, which made it impossible to copy text
from system notes, thinking blocks, or tool output. Drop the
body-start gate so the rendered transcript block is selectable in full.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hunter Bown
2026-05-02 21:54:57 -05:00
parent 7125172f67
commit a4102ccad4
3 changed files with 18 additions and 69 deletions
+1 -28
View File
@@ -6451,9 +6451,6 @@ fn selection_to_text(app: &App) -> Option<String> {
let mut selected_lines = Vec::new();
#[allow(clippy::needless_range_loop)]
for line_index in start_index..=end_index {
let Some(body_start) = llm_io_selection_body_start(app, line_index) else {
continue;
};
let line_text = line_to_plain(&lines[line_index]);
let line_width = text_display_width(&line_text);
let (col_start, col_end) = if start_index == end_index {
@@ -6466,36 +6463,12 @@ fn selection_to_text(app: &App) -> Option<String> {
(0, line_width)
};
if col_end <= body_start {
continue;
}
let slice = slice_text(&line_text, col_start.max(body_start), col_end);
let slice = slice_text(&line_text, col_start, col_end);
selected_lines.push(slice);
}
Some(selected_lines.join("\n"))
}
fn llm_io_selection_body_start(app: &App, line_index: usize) -> Option<usize> {
const MESSAGE_BODY_START_COLUMN: usize = 2;
let (filtered_cell_index, _) = app
.viewport
.transcript_cache
.line_meta()
.get(line_index)?
.cell_line()?;
let cell_index = app
.collapsed_cell_map
.get(filtered_cell_index)
.copied()
.unwrap_or(filtered_cell_index);
match app.cell_at_virtual_index(cell_index)? {
HistoryCell::User { .. } | HistoryCell::Assistant { .. } => Some(MESSAGE_BODY_START_COLUMN),
_ => None,
}
}
fn open_pager_for_selection(app: &mut App) -> bool {
let Some(text) = selection_to_text(app) else {
return false;
+16 -10
View File
@@ -94,24 +94,31 @@ fn selection_to_text_handles_multiline_and_reversed_endpoints() {
column: 6,
});
assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\ngam"));
assert_eq!(selection_to_text(&app).as_deref(), Some("a beta\n gam"));
}
#[test]
fn selection_to_text_only_copies_user_and_assistant_bodies() {
fn selection_to_text_copies_rendered_transcript_block() {
let mut app = create_test_app();
app.history = vec![
HistoryCell::System {
content: "skip system".to_string(),
content: "copy system".to_string(),
},
HistoryCell::User {
content: "copy user".to_string(),
},
HistoryCell::Thinking {
content: "skip thinking".to_string(),
content: "copy thinking".to_string(),
streaming: false,
duration_secs: Some(1.0),
},
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "exec_shell".to_string(),
status: ToolStatus::Success,
input_summary: Some("cargo check".to_string()),
output: Some("tool output line".to_string()),
prompts: None,
})),
HistoryCell::Assistant {
content: "copy assistant".to_string(),
streaming: false,
@@ -139,12 +146,11 @@ fn selection_to_text_only_copies_user_and_assistant_bodies() {
});
let selected = selection_to_text(&app).expect("selection text");
assert!(selected.contains("copy user"), "{selected:?}");
assert!(selected.contains("copy assistant"), "{selected:?}");
assert!(!selected.contains("skip system"), "{selected:?}");
assert!(!selected.contains("skip thinking"), "{selected:?}");
assert!(!selected.contains('▎'), "{selected:?}");
assert!(!selected.contains('●'), "{selected:?}");
assert!(selected.contains("Note copy system"), "{selected:?}");
assert!(selected.contains("copy user"), "{selected:?}");
assert!(selected.contains("copy thinking"), "{selected:?}");
assert!(selected.contains("tool output line"), "{selected:?}");
assert!(selected.contains("● copy assistant"), "{selected:?}");
}
#[test]
+1 -31
View File
@@ -46,7 +46,6 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const SEND_FLASH_DURATION: Duration = Duration::from_millis(500);
const COMPOSER_PANEL_HEIGHT: u16 = 2;
const MESSAGE_SELECTION_BODY_START_COLUMN: usize = 2;
pub struct ChatWidget {
content_area: Rect,
@@ -1302,11 +1301,8 @@ fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) {
if line_index < start.line_index || line_index > end.line_index {
continue;
}
let Some(body_start) = llm_io_selection_body_start(app, line_index) else {
continue;
};
let (mut col_start, col_end) = if start.line_index == end.line_index {
let (col_start, col_end) = if start.line_index == end.line_index {
(start.column, end.column)
} else if line_index == start.line_index {
(start.column, usize::MAX)
@@ -1316,11 +1312,6 @@ fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) {
(0, usize::MAX)
};
if col_end <= body_start {
continue;
}
col_start = col_start.max(body_start);
if col_start == 0 && col_end == usize::MAX {
for span in &mut line.spans {
span.style = span.style.patch(selection_style);
@@ -1332,27 +1323,6 @@ fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) {
}
}
fn llm_io_selection_body_start(app: &App, line_index: usize) -> Option<usize> {
let (filtered_cell_index, _) = app
.viewport
.transcript_cache
.line_meta()
.get(line_index)?
.cell_line()?;
let cell_index = app
.collapsed_cell_map
.get(filtered_cell_index)
.copied()
.unwrap_or(filtered_cell_index);
match app.cell_at_virtual_index(cell_index)? {
HistoryCell::User { .. } | HistoryCell::Assistant { .. } => {
Some(MESSAGE_SELECTION_BODY_START_COLUMN)
}
_ => None,
}
}
fn apply_detail_target_highlight(
lines: &mut [Line<'static>],
top: usize,