mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
committed by
copyberry
parent
950dd184a1
commit
df6a54ee85
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}))
|
||||
);
|
||||
}
|
||||
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user