Add a response target picker to /copy (#39997)

## What changed

- Open a picker for `/copy` with the whole response plus each fenced code block and blockquote from the latest response.
- Label code blocks by language, show content previews, and preserve source whitespace and nested quote Markdown when copying an individual target.
- Keep the picker from disturbing terminal scrollback or queued input, retain clipboard leases, and avoid writing copied content to the session log.

## Testing

- Add coverage for target extraction, picker navigation and cancellation, exact copied content, plan responses, queued input, scrollback preservation, clipboard failures, and session logging.

GitOrigin-RevId: 77a35bc99f4f45932d39914f9a709b30fe8cd7c2
This commit is contained in:
Benjamin Carlsson
2026-08-21 19:23:26 +00:00
committed by copyberry
parent 950dd184a1
commit df6a54ee85
15 changed files with 766 additions and 30 deletions
+3
View File
@@ -141,6 +141,9 @@ impl App {
self.chat_widget.maybe_send_next_queued_input();
}
}
AppEvent::CopySelection { text, label } => {
self.chat_widget.copy_selection(text, label);
}
AppEvent::ClearUi { name } => {
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
self.reset_app_ui_state_after_clear();
+71
View File
@@ -5825,6 +5825,77 @@ async fn resize_reflow_wraps_transcript_early_when_pet_is_enabled() {
);
}
#[tokio::test]
async fn copy_picker_opening_preserves_terminal_scrollback_without_reflow() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let response = "Existing response\n\n```rust\nkeep_scrollback();\n```";
app.chat_widget.handle_server_notification(
ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification {
thread_id: String::new(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
item: serde_json::from_value(serde_json::json!({
"type": "agentMessage",
"id": "message-1",
"text": response,
}))
.expect("valid completed agent message"),
}),
/*replay_kind*/ None,
);
while app_event_rx.try_recv().is_ok() {}
app.transcript_cells = vec![
plain_line_cell("Older terminal scrollback"),
Arc::new(AgentMarkdownCell::new(
response.to_string(),
Path::new("/tmp"),
)),
];
app.deferred_history_lines = vec![Line::from("Buffered scrollback").into()];
app.has_emitted_history_lines = true;
app.scrollback_has_older_history = true;
app.transcript_reflow.note_width(/*width*/ 80);
let before = app
.render_transcript_lines_for_reflow(/*width*/ 80)
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>();
app.chat_widget.apply_external_edit("/copy".to_string());
assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Whole response"));
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("Whole response"));
assert_eq!(
app.render_transcript_lines_for_reflow(/*width*/ 80)
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>(),
before
);
assert_eq!(app.transcript_cells.len(), 2);
assert_eq!(app.deferred_history_lines.len(), 1);
assert!(app.has_emitted_history_lines);
assert!(app.scrollback_has_older_history);
assert!(!app.transcript_reflow.has_pending_reflow());
while let Ok(event) = app_event_rx.try_recv() {
assert!(
!matches!(
event,
AppEvent::ClearUi { .. }
| AppEvent::ClearUiAndSubmitUserMessage { .. }
| AppEvent::InsertHistoryCell(_)
| AppEvent::ConsolidateAgentMessage { .. }
),
"opening a picker must not modify terminal scrollback: {event:?}"
);
}
}
#[tokio::test]
async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
+6
View File
@@ -287,6 +287,12 @@ pub(crate) enum AppEvent {
destination: TranscriptExportDestination,
},
/// Copy a picker selection while retaining its clipboard lease in the chat widget.
CopySelection {
text: Arc<str>,
label: String,
},
/// Persist a submitted prompt in the cross-session message history.
AppendMessageHistoryEntry {
thread_id: ThreadId,
@@ -25,7 +25,7 @@ expression: commands
/compact - summarize conversation to prevent hitting the context limit
/agents - view and switch between all active agent sessions
/side - start a side conversation in an ephemeral fork
/copy - copy last response as markdown
/copy - copy the last response, code block, or quote
/export - export the conversation as markdown
/raw - toggle raw scrollback mode for copy-friendly terminal selection
/diff - show git diff (including untracked files)
-7
View File
@@ -991,13 +991,6 @@ impl ChatWidget {
}
}
/// Record or update the raw markdown for the current agent turn.
fn record_agent_markdown(&mut self, message: &str) {
if !message.is_empty() {
self.transcript.record_agent_markdown(message.to_string());
}
}
pub(crate) fn open_feedback_note(
&mut self,
category: crate::app_event::FeedbackCategory,
@@ -326,6 +326,99 @@ impl ChatWidget {
self.request_redraw();
}
pub(super) fn show_copy_picker(&mut self) {
let Some(markdown) = self
.transcript
.last_agent_markdown
.clone()
.filter(|markdown| !markdown.is_empty())
else {
self.copy_last_agent_markdown();
return;
};
let mut choices = vec![(
"Whole response".to_string(),
Arc::<str>::from(markdown.as_str()),
)];
let source = self
.transcript
.last_agent_source
.as_deref()
.unwrap_or(&markdown);
choices.extend(
crate::markdown::extract_copy_targets(source)
.into_iter()
.filter_map(|target| match target {
crate::markdown::CopyTarget::Code { language, content } => Some((
language.map_or_else(
|| "Code block".to_string(),
|language| format!("{language} code"),
),
content,
)),
crate::markdown::CopyTarget::Quote(content) => {
let content: String = content
.split_inclusive('\n')
.map(|line| crate::git_action_directives::strip_line_directives(line).0)
.collect();
(!content.trim().is_empty())
.then(|| ("Blockquote".to_string(), Arc::from(content)))
}
}),
);
let items = choices
.into_iter()
.map(|(label, text)| {
let description = text
.lines()
.find(|line| !line.trim().is_empty())
.map(|line| line.trim().chars().take(72).collect());
SelectionItem {
name: label.clone(),
description,
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::CopySelection {
text: Arc::clone(&text),
label: label.clone(),
});
})],
dismiss_on_select: true,
..Default::default()
}
})
.collect();
self.show_selection_view(SelectionViewParams {
title: Some("Copy from response".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
self.defer_input_until_settings_applied();
}
pub(crate) fn copy_selection(&mut self, text: Arc<str>, label: String) {
self.copy_selection_with(&text, &label, crate::clipboard_copy::copy_to_clipboard);
}
pub(super) fn copy_selection_with(
&mut self,
text: &str,
label: &str,
copy_fn: impl FnOnce(&str) -> Result<Option<crate::clipboard_copy::ClipboardLease>, String>,
) {
match copy_fn(text) {
Ok(lease) => {
self.clipboard_lease = lease;
self.add_info_message(format!("Copied {label} to clipboard"), /*hint*/ None);
}
Err(error) => self.add_error_message(format!("Copy failed: {error}")),
}
self.request_redraw();
}
#[cfg(test)]
pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> {
self.transcript.last_agent_markdown.as_deref()
@@ -402,7 +402,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::Logout);
}
SlashCommand::Copy => {
self.copy_last_agent_markdown();
self.show_copy_picker();
}
SlashCommand::Export => {
self.show_transcript_export_popup();
+10 -6
View File
@@ -174,14 +174,17 @@ impl ChatWidget {
}
pub(super) fn on_plan_item_completed(&mut self, text: String) {
let streamed_plan = self.transcript.plan_delta_buffer.trim().to_string();
let plan_text = if text.trim().is_empty() {
streamed_plan
let (plan_text, source) = if text.trim().is_empty() {
(
self.transcript.plan_delta_buffer.trim().to_string(),
self.transcript.plan_delta_buffer.clone(),
)
} else {
text
(text.clone(), text)
};
if !plan_text.trim().is_empty() {
self.record_agent_markdown(&plan_text);
self.transcript
.record_agent_markdown(plan_text.clone(), source);
self.transcript.latest_proposed_plan_markdown = Some(plan_text.clone());
}
// Plan commit ticks can hide the status row; remember whether we streamed plan output so
@@ -331,7 +334,8 @@ impl ChatWidget {
if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None)
&& !parsed.visible_markdown.is_empty()
{
self.record_agent_markdown(&parsed.visible_markdown);
self.transcript
.record_agent_markdown(parsed.visible_markdown.clone(), message);
}
if !from_replay
&& let Some(cwd) = parsed.last_created_branch_cwd()
@@ -109,6 +109,17 @@ fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEv
}
}
fn next_copy_selection(
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> (String, String) {
let selection = match rx.try_recv() {
Ok(AppEvent::CopySelection { text, label }) => (text.to_string(), label),
other => panic!("expected selected clipboard content, got {other:?}"),
};
assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed));
selection
}
#[tokio::test]
async fn service_tier_commands_lowercase_catalog_names() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
@@ -1717,8 +1728,9 @@ async fn slash_copy_state_tracks_turn_complete_final_reply() {
#[tokio::test]
async fn slash_copy_state_tracks_plan_item_completion() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let plan_text = "## Plan\n\n1. Build it\n2. Test it".to_string();
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
complete_turn_with_message(&mut chat, "previous", Some("```rust\nstale();\n```"));
let plan_text = "## Plan\n\n1. Build it\n2. Test it\n\n```sh\r\njust test \r\n```".to_string();
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
@@ -1739,6 +1751,334 @@ async fn slash_copy_state_tracks_plan_item_completion() {
chat.pending_notification,
Some(Notification::AgentTurnComplete { ref response }) if response == &plan_text
);
let _ = drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Copy);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(popup.contains("Whole response"), "picker: {popup}");
assert!(popup.contains("sh code"), "picker: {popup}");
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
(plan_text, "Whole response".to_string())
);
chat.dispatch_command(SlashCommand::Copy);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
("just test \r\n".to_string(), "sh code".to_string())
);
chat.transcript.plan_delta_buffer = " ```sh\n echo hi \n ```\n".to_string();
chat.on_plan_item_completed(String::new());
assert_eq!(
chat.last_agent_markdown_text(),
Some("```sh\n echo hi \n ```")
);
let _ = drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Copy);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
("echo hi \n".to_string(), "sh code".to_string())
);
}
#[tokio::test]
async fn slash_copy_picker_preserves_completed_source_whitespace_and_hides_directives() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let source = concat!(
"Intro ::git-push{cwd=\"/repo\"}\r\n\r\n",
"```powershell\r\nWrite-Output value \r\nWrite-Output done\t\r\n```\r\n\r\n",
"> Keep **formatting** \r\n> > Nested quote\r\n> hidden ::git-stage{cwd=\"/repo\"}\r\n\r\n",
"> ::git-stage{cwd=\"/repo\"}\r\n"
);
complete_turn_with_message(&mut chat, "turn-1", Some(source));
while rx.try_recv().is_ok() {}
for (key, expected, label) in [
(
'1',
"Intro\n\n```powershell\nWrite-Output value\nWrite-Output done\n```\n\n> Keep **formatting**\n> > Nested quote\n> hidden\n\n>",
"Whole response",
),
(
'2',
"Write-Output value \r\nWrite-Output done\t\r\n",
"powershell code",
),
(
'3',
"Keep **formatting** \r\n> Nested quote\r\nhidden \r\n",
"Blockquote",
),
] {
chat.dispatch_command(SlashCommand::Copy);
assert_eq!(
render_bottom_popup(&chat, /*width*/ 100)
.matches("Blockquote")
.count(),
1
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
(expected.to_string(), label.to_string())
);
}
chat.transcript.reset_copy_history();
assert_eq!(
(
chat.transcript.last_agent_markdown.as_deref(),
chat.transcript.last_agent_source.as_deref()
),
(None, None)
);
}
#[tokio::test]
async fn slash_copy_picker_defers_queued_input_until_selection_or_cancellation_settles() {
for queued in ["/new", "/clear", "follow-up prompt"] {
for close_key in [KeyCode::Enter, KeyCode::Esc] {
let (mut chat, mut rx, mut op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
complete_turn_with_message(&mut chat, "previous", Some("Previous response"));
handle_turn_started(&mut chat, "active");
queue_composer_text_with_tab(&mut chat, queued);
chat.dispatch_command(SlashCommand::Copy);
complete_turn_with_message(&mut chat, "active", Some("New response"));
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Previous response"));
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
while let Ok(event) = rx.try_recv() {
assert!(!matches!(
event,
AppEvent::NewSession { .. } | AppEvent::ClearUi { .. }
));
}
chat.handle_key_event(KeyEvent::new(close_key, KeyModifiers::NONE));
if close_key == KeyCode::Enter {
assert_eq!(
next_copy_selection(&mut rx),
(
"Previous response".to_string(),
"Whole response".to_string()
)
);
} else {
assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed));
}
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
chat.set_queue_autosend_suppressed(/*suppressed*/ false);
chat.maybe_send_next_queued_input();
assert!(chat.input_queue.queued_user_messages.is_empty());
match queued {
"/new" => assert!(
std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::NewSession { .. }))
),
"/clear" => assert!(
std::iter::from_fn(|| rx.try_recv().ok())
.any(|event| matches!(event, AppEvent::ClearUi { .. }))
),
_ => {
assert_matches!(next_submit_op(&mut op_rx), Op::UserTurn { items, .. } if items == vec![UserInput::Text { text: queued.to_string(), text_elements: Vec::new() }])
}
}
}
}
}
#[tokio::test]
async fn slash_copy_picker_previews_whole_response_code_blocks_and_blockquotes() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.transcript.last_agent_markdown = Some(
"Overview\n\n```python\nprint('first')\nprint('second')\n```\n\n> Important quote\n\n```sh\necho done\n```"
.to_string(),
);
chat.dispatch_command(SlashCommand::Copy);
let popup = render_bottom_popup(&chat, /*width*/ 100);
for expected in [
"1. Whole response",
"Overview",
"2. python code",
"print('first')",
"3. Blockquote",
"Important quote",
"4. sh code",
"echo done",
] {
assert!(popup.contains(expected), "expected {expected:?} in {popup}");
}
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn slash_copy_picker_waits_for_submission_after_typing_or_autocomplete() {
for select_from_autocomplete in [false, true] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.transcript.last_agent_markdown = Some("Ready to copy".to_string());
chat.bottom_pane
.set_composer_text("/cop".to_string(), Vec::new(), Vec::new());
let key = if select_from_autocomplete {
KeyCode::Tab
} else {
KeyCode::Char('y')
};
chat.handle_key_event(KeyEvent::new(key, KeyModifiers::NONE));
if !select_from_autocomplete {
std::thread::sleep(crate::bottom_pane::ChatComposer::recommended_paste_flush_delay());
chat.handle_paste_burst_tick(FrameRequester::test_dummy());
}
assert_eq!(chat.bottom_pane.composer_text().trim(), "/copy");
assert!(
!render_bottom_popup(&chat, /*width*/ 80).contains("Whole response"),
"preparing /copy must not open the picker"
);
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(popup.contains("Whole response"), "picker: {popup}");
assert!(chat.bottom_pane.composer_text().is_empty());
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
}
#[tokio::test]
async fn slash_copy_picker_waits_for_submission_after_paste() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.transcript.last_agent_markdown = Some("Ready to copy".to_string());
chat.handle_paste("/copy".to_string());
assert_eq!(chat.bottom_pane.composer_text(), "/copy");
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Whole response"));
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Whole response"));
assert!(chat.bottom_pane.composer_text().is_empty());
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn slash_copy_picker_numeric_shortcuts_copy_whole_response_and_exact_code() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let markdown = "Intro\n\n```rust\nfn main() {\n run();\n}\n```";
chat.transcript.last_agent_markdown = Some(markdown.to_string());
chat.dispatch_command(SlashCommand::Copy);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
(markdown.to_string(), "Whole response".to_string())
);
chat.dispatch_command(SlashCommand::Copy);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
(
"fn main() {\n run();\n}\n".to_string(),
"rust code".to_string()
)
);
}
#[tokio::test]
async fn slash_copy_picker_preserves_nested_blockquote_markdown_and_code() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.transcript.last_agent_markdown =
Some("> **outer**\n> > _nested_\n> ```sh\n> nested()\n> ```\n".to_string());
chat.dispatch_command(SlashCommand::Copy);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
(
"**outer**\n> _nested_\n```sh\nnested()\n```\n".to_string(),
"Blockquote".to_string()
)
);
chat.dispatch_command(SlashCommand::Copy);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
("nested()\n".to_string(), "sh code".to_string())
);
}
#[tokio::test]
async fn slash_copy_picker_supports_arrow_navigation_enter_and_scrolling() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.transcript.last_agent_markdown = Some(
(1..=11)
.map(|index| format!("```sh\nexample_{index}()\n```\n"))
.collect(),
);
chat.dispatch_command(SlashCommand::Copy);
for _ in 0..11 {
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
}
assert!(
render_bottom_popup(&chat, /*width*/ 80).contains("example_11()"),
"later rows must remain reachable through scrolling"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
("example_10()\n".to_string(), "sh code".to_string())
);
}
#[tokio::test]
async fn slash_copy_picker_escape_dismisses_without_copying() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.transcript.last_agent_markdown = Some("Nothing copied".to_string());
chat.dispatch_command(SlashCommand::Copy);
assert!(!chat.bottom_pane.no_modal_or_popup_active());
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(chat.bottom_pane.no_modal_or_popup_active());
assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed));
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn slash_copy_picker_remains_available_from_parent_owned_threads() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.transcript.last_agent_markdown = Some("Safe local copy".to_string());
chat.set_parent_owned_thread();
chat.bottom_pane
.set_composer_text("/cop".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(!render_bottom_popup(&chat, /*width*/ 80).contains("Whole response"));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("Whole response"));
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
("Safe local copy".to_string(), "Whole response".to_string())
);
assert!(
op_rx.try_recv().is_err(),
"copy must not submit an agent turn"
);
}
#[tokio::test]
@@ -1996,6 +2336,22 @@ async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() {
rendered.contains("Copy failed: blocked"),
"expected failure message, got {rendered:?}"
);
chat.copy_selection_with("print('ok')\n", "python code", |content| {
assert_eq!(content, "print('ok')\n");
Ok(Some(crate::clipboard_copy::ClipboardLease::test()))
});
assert!(chat.clipboard_lease.is_some());
let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]);
assert!(rendered.contains("Copied python code to clipboard"));
chat.copy_selection_with("print('blocked')\n", "python code", |content| {
assert_eq!(content, "print('blocked')\n");
Err("blocked selection".to_string())
});
assert!(chat.clipboard_lease.is_some());
let rendered = lines_to_single_string(&drain_insert_history(&mut rx)[0]);
assert!(rendered.contains("Copy failed: blocked selection"));
}
#[tokio::test]
+6 -2
View File
@@ -32,8 +32,10 @@ pub(super) struct TranscriptState {
pub(super) active_cell_revision: u64,
/// One bounded entry shared by layout and paint across unchanged active-cell frames.
pub(super) active_cell_layout: Cell<Option<ActiveCellLayoutCache>>,
/// Raw markdown of the most recently completed agent response.
/// Markdown of the most recently completed agent response for whole-response copying.
pub(super) last_agent_markdown: Option<String>,
/// Original source of that response, before display sanitization, for exact block copying.
pub(super) last_agent_source: Option<String>,
pub(super) last_completed_agent_message: Option<(String, String)>,
/// Raw markdown of the most recently completed proposed plan.
pub(super) latest_proposed_plan_markdown: Option<String>,
@@ -80,13 +82,15 @@ impl TranscriptState {
active_cell
}
pub(super) fn record_agent_markdown(&mut self, markdown: String) {
pub(super) fn record_agent_markdown(&mut self, markdown: String, source: String) {
self.last_agent_markdown = Some(markdown);
self.last_agent_source = Some(source);
self.saw_copy_source_this_turn = true;
}
pub(super) fn reset_copy_history(&mut self) {
self.last_agent_markdown = None;
self.last_agent_source = None;
self.saw_copy_source_this_turn = false;
}
+1 -1
View File
@@ -131,7 +131,7 @@ fn rewrite_code_comment_line(line: &str, cwd: &Path) -> Option<String> {
))
}
fn strip_line_directives(line: &str) -> (String, Vec<GitActionDirective>) {
pub(crate) fn strip_line_directives(line: &str) -> (String, Vec<GitActionDirective>) {
let mut visible = String::new();
let mut actions = Vec::new();
let mut remaining = line;
+154
View File
@@ -19,10 +19,15 @@
//! buffers the entire fence body before deciding, only unwraps fences whose
//! info string is `md` or `markdown` AND whose body contains a
//! header+delimiter pair, and degrades gracefully on unclosed fences.
use pulldown_cmark::CodeBlockKind;
use pulldown_cmark::Event;
use pulldown_cmark::Tag;
use pulldown_cmark::TagEnd;
use ratatui::text::Line;
use std::borrow::Cow;
use std::ops::Range;
use std::path::Path;
use std::sync::Arc;
use crate::inline_visualization::InlineVisualizationContext;
use crate::inline_visualization::rewrite_inline_visualizations;
@@ -134,6 +139,93 @@ pub(crate) fn render_streaming_markdown_agent_with_links_and_cwd(
rendered
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CopyTarget {
Code {
language: Option<String>,
content: Arc<str>,
},
Quote(Arc<str>),
}
pub(crate) fn extract_copy_targets(markdown_source: &str) -> Vec<CopyTarget> {
let mut targets = Vec::new();
let mut quote: Option<(Range<usize>, usize, bool)> = None;
let mut quote_depth = 0usize;
let mut in_code_block = false;
let mut code = None;
for (event, range) in pulldown_cmark::Parser::new(markdown_source).into_offset_iter() {
match event {
Event::Start(Tag::BlockQuote) => {
if quote_depth == 0 {
quote = Some((range, targets.len(), false));
}
quote_depth += 1;
}
Event::End(TagEnd::BlockQuote) => {
quote_depth = quote_depth.saturating_sub(1);
if quote_depth == 0 {
quote = None;
}
}
Event::Start(Tag::CodeBlock(kind)) => {
in_code_block = true;
if let CodeBlockKind::Fenced(info) = kind {
let language = info
.split([',', ' ', '\t'])
.next()
.filter(|language| !language.is_empty())
.map(str::to_owned);
code = Some((language, String::new()));
}
}
Event::Text(text) | Event::Code(text) => {
if in_code_block {
if let Some((_, content)) = &mut code {
if text.starts_with('\n')
&& range.start > 0
&& markdown_source.as_bytes()[range.start - 1] == b'\r'
{
content.push('\r');
}
content.push_str(&text);
}
} else if let Some((span, position, emitted)) = &mut quote
&& !*emitted
&& !crate::git_action_directives::strip_line_directives(&text)
.0
.trim()
.is_empty()
{
let content = markdown_source[span.clone()]
.split_inclusive('\n')
.map(|line| {
line.trim_start_matches(' ')
.strip_prefix('>')
.map_or(line, |line| line.strip_prefix(' ').unwrap_or(line))
})
.collect::<String>();
targets.insert(*position, CopyTarget::Quote(content.into()));
*emitted = true;
}
}
Event::End(TagEnd::CodeBlock) => {
in_code_block = false;
if let Some((language, content)) = code.take() {
targets.push(CopyTarget::Code {
language,
content: content.into(),
});
}
}
_ => {}
}
}
targets
}
/// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare
/// markdown so `pulldown-cmark` parses the tables natively.
///
@@ -359,6 +451,68 @@ mod tests {
use pretty_assertions::assert_eq;
use ratatui::text::Line;
#[test]
fn copy_targets_preserve_fenced_code_language_and_exact_content() {
let markdown =
"```python title=example\nprint('hi')\n```\n\n ignored()\n\n~~~\nplain()\n~~~";
assert_eq!(
extract_copy_targets(markdown),
vec![
CopyTarget::Code {
language: Some("python".to_string()),
content: Arc::from("print('hi')\n"),
},
CopyTarget::Code {
language: None,
content: Arc::from("plain()\n"),
},
]
);
assert_eq!(
extract_copy_targets("```powershell\r\nGet-Item .\r\nWrite-Output ok\r\n```\r\n"),
vec![CopyTarget::Code {
language: Some("powershell".to_string()),
content: Arc::from("Get-Item .\r\nWrite-Output ok\r\n"),
}]
);
}
#[test]
fn copy_targets_preserve_nested_quote_markdown_and_source_order() {
let markdown = "> outer **bold**\n> > inner *quote*\n> ```sh\n> nested()\n> ```\n";
assert_eq!(
extract_copy_targets(markdown),
vec![
CopyTarget::Quote(Arc::from(
"outer **bold**\n> inner *quote*\n```sh\nnested()\n```\n"
)),
CopyTarget::Code {
language: Some("sh".to_string()),
content: Arc::from("nested()\n"),
},
]
);
}
#[test]
fn copy_targets_exclude_blockquotes_without_prose() {
let markdown = concat!(
"> > ::git-stage{cwd=\"/repo\"}\n\n",
"> ::git-stage{cwd=\"/repo\"}\n> ```sh\n> code()\n> ```\n\n",
"> quoted prose\n"
);
assert_eq!(
extract_copy_targets(markdown),
vec![
CopyTarget::Code {
language: Some("sh".to_string()),
content: Arc::from("code()\n"),
},
CopyTarget::Quote(Arc::from("quoted prose\n")),
]
);
}
fn lines_to_strings(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
+17 -9
View File
@@ -120,8 +120,12 @@ pub(crate) fn maybe_init(config: &Config) {
}
pub(crate) fn log_inbound_app_event(event: &AppEvent) {
log_inbound_app_event_with(&LOGGER, event);
}
fn log_inbound_app_event_with(logger: &SessionLogger, event: &AppEvent) {
// Log only if enabled
if !LOGGER.is_enabled() {
if !logger.is_enabled() {
return;
}
@@ -132,7 +136,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"dir": "to_tui",
"kind": "new_session",
});
LOGGER.write_json_line(value);
logger.write_json_line(value);
}
AppEvent::ClearUi { .. } => {
let value = json!({
@@ -140,7 +144,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"dir": "to_tui",
"kind": "clear_ui",
});
LOGGER.write_json_line(value);
logger.write_json_line(value);
}
AppEvent::InsertHistoryCell(cell) => {
let value = json!({
@@ -149,7 +153,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"kind": "insert_history_cell",
"lines": cell.transcript_lines(u16::MAX).len(),
});
LOGGER.write_json_line(value);
logger.write_json_line(value);
}
AppEvent::StartFileSearch(query) => {
let value = json!({
@@ -158,7 +162,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"kind": "file_search_start",
"query": query,
});
LOGGER.write_json_line(value);
logger.write_json_line(value);
}
AppEvent::FileSearchResult { query, matches } => {
let value = json!({
@@ -168,7 +172,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"query": query,
"matches": matches.len(),
});
LOGGER.write_json_line(value);
logger.write_json_line(value);
}
AppEvent::PetPreviewLoaded { request_id, result } => {
let value = json!({
@@ -179,7 +183,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"request_id": request_id,
"ok": result.is_ok(),
});
LOGGER.write_json_line(value);
logger.write_json_line(value);
}
AppEvent::PetSelectionLoaded {
request_id,
@@ -195,7 +199,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"pet_id": pet_id,
"ok": result.is_ok(),
});
LOGGER.write_json_line(value);
logger.write_json_line(value);
}
// Noise or control flow – record variant only
other => {
@@ -206,7 +210,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
"kind": "app_event",
"variant": variant,
});
LOGGER.write_json_line(value);
logger.write_json_line(value);
}
}
}
@@ -242,3 +246,7 @@ where
});
LOGGER.write_json_line(value);
}
#[cfg(test)]
#[path = "session_log_tests.rs"]
mod tests;
+44
View File
@@ -0,0 +1,44 @@
use super::*;
use pretty_assertions::assert_eq;
use std::sync::Arc;
#[test]
fn inbound_fallback_records_only_variant_names() {
let directory = tempfile::tempdir().expect("temporary log directory");
let path = directory.path().join("session.jsonl");
let logger = SessionLogger::new();
logger.open(path.clone()).expect("open session log");
for event in [
AppEvent::CopySelection {
text: Arc::from("private plan without parentheses"),
label: "private label".to_string(),
},
AppEvent::CopySelection {
text: Arc::from("private code(with parentheses)"),
label: "private label".to_string(),
},
AppEvent::ConsolidateProposedPlan("private tuple payload".to_string()),
AppEvent::SettingsSelectionClosed,
] {
log_inbound_app_event_with(&logger, &event);
}
let records = std::fs::read_to_string(path)
.expect("read session log")
.lines()
.map(|line| {
let mut record: serde_json::Value = serde_json::from_str(line).expect("valid log JSON");
record.as_object_mut().expect("log object").remove("ts");
record
})
.collect::<Vec<_>>();
assert_eq!(
records,
[
"CopySelection",
"CopySelection",
"ConsolidateProposedPlan",
"SettingsSelectionClosed"
]
.map(|variant| json!({"dir": "to_tui", "kind": "app_event", "variant": variant}))
);
}
+1 -1
View File
@@ -99,7 +99,7 @@ impl SlashCommand {
SlashCommand::Fork => "fork the current chat",
SlashCommand::App => "continue this session in the Desktop app",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Copy => "copy last response as markdown",
SlashCommand::Copy => "copy the last response, code block, or quote",
SlashCommand::Export => "export the conversation as markdown",
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
SlashCommand::Diff => "show git diff (including untracked files)",