mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +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:
1 parent
950dd184a1
commit
df6a54ee85
15 files changed
+766
-30
No files matched your search
@@ -141,6 +141,9 @@ impl App {
|
|||||||
self.chat_widget.maybe_send_next_queued_input();
|
self.chat_widget.maybe_send_next_queued_input();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppEvent::CopySelection { text, label } => {
|
||||||
|
self.chat_widget.copy_selection(text, label);
|
||||||
|
}
|
||||||
AppEvent::ClearUi { name } => {
|
AppEvent::ClearUi { name } => {
|
||||||
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
|
self.clear_terminal_ui(tui, /*redraw_header*/ false)?;
|
||||||
self.reset_app_ui_state_after_clear();
|
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]
|
#[tokio::test]
|
||||||
async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() {
|
async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() {
|
||||||
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
|
||||||
|
|||||||
@@ -287,6 +287,12 @@ pub(crate) enum AppEvent {
|
|||||||
destination: TranscriptExportDestination,
|
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.
|
/// Persist a submitted prompt in the cross-session message history.
|
||||||
AppendMessageHistoryEntry {
|
AppendMessageHistoryEntry {
|
||||||
thread_id: ThreadId,
|
thread_id: ThreadId,
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ expression: commands
|
|||||||
/compact - summarize conversation to prevent hitting the context limit
|
/compact - summarize conversation to prevent hitting the context limit
|
||||||
/agents - view and switch between all active agent sessions
|
/agents - view and switch between all active agent sessions
|
||||||
/side - start a side conversation in an ephemeral fork
|
/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
|
/export - export the conversation as markdown
|
||||||
/raw - toggle raw scrollback mode for copy-friendly terminal selection
|
/raw - toggle raw scrollback mode for copy-friendly terminal selection
|
||||||
/diff - show git diff (including untracked files)
|
/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(
|
pub(crate) fn open_feedback_note(
|
||||||
&mut self,
|
&mut self,
|
||||||
category: crate::app_event::FeedbackCategory,
|
category: crate::app_event::FeedbackCategory,
|
||||||
|
|||||||
@@ -326,6 +326,99 @@ impl ChatWidget {
|
|||||||
self.request_redraw();
|
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)]
|
#[cfg(test)]
|
||||||
pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> {
|
pub(crate) fn last_agent_markdown_text(&self) -> Option<&str> {
|
||||||
self.transcript.last_agent_markdown.as_deref()
|
self.transcript.last_agent_markdown.as_deref()
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ impl ChatWidget {
|
|||||||
self.app_event_tx.send(AppEvent::Logout);
|
self.app_event_tx.send(AppEvent::Logout);
|
||||||
}
|
}
|
||||||
SlashCommand::Copy => {
|
SlashCommand::Copy => {
|
||||||
self.copy_last_agent_markdown();
|
self.show_copy_picker();
|
||||||
}
|
}
|
||||||
SlashCommand::Export => {
|
SlashCommand::Export => {
|
||||||
self.show_transcript_export_popup();
|
self.show_transcript_export_popup();
|
||||||
|
|||||||
@@ -174,14 +174,17 @@ impl ChatWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn on_plan_item_completed(&mut self, text: String) {
|
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, source) = if text.trim().is_empty() {
|
||||||
let plan_text = if text.trim().is_empty() {
|
(
|
||||||
streamed_plan
|
self.transcript.plan_delta_buffer.trim().to_string(),
|
||||||
|
self.transcript.plan_delta_buffer.clone(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
text
|
(text.clone(), text)
|
||||||
};
|
};
|
||||||
if !plan_text.trim().is_empty() {
|
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());
|
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
|
// 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)
|
if matches!(item.phase, Some(MessagePhase::FinalAnswer) | None)
|
||||||
&& !parsed.visible_markdown.is_empty()
|
&& !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
|
if !from_replay
|
||||||
&& let Some(cwd) = parsed.last_created_branch_cwd()
|
&& 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]
|
#[tokio::test]
|
||||||
async fn service_tier_commands_lowercase_catalog_names() {
|
async fn service_tier_commands_lowercase_catalog_names() {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
|
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]
|
#[tokio::test]
|
||||||
async fn slash_copy_state_tracks_plan_item_completion() {
|
async fn slash_copy_state_tracks_plan_item_completion() {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||||
let plan_text = "## Plan\n\n1. Build it\n2. Test it".to_string();
|
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(
|
chat.handle_server_notification(
|
||||||
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
ServerNotification::ItemCompleted(ItemCompletedNotification {
|
||||||
@@ -1739,6 +1751,334 @@ async fn slash_copy_state_tracks_plan_item_completion() {
|
|||||||
chat.pending_notification,
|
chat.pending_notification,
|
||||||
Some(Notification::AgentTurnComplete { ref response }) if response == &plan_text
|
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]
|
#[tokio::test]
|
||||||
@@ -1996,6 +2336,22 @@ async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() {
|
|||||||
rendered.contains("Copy failed: blocked"),
|
rendered.contains("Copy failed: blocked"),
|
||||||
"expected failure message, got {rendered:?}"
|
"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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ pub(super) struct TranscriptState {
|
|||||||
pub(super) active_cell_revision: u64,
|
pub(super) active_cell_revision: u64,
|
||||||
/// One bounded entry shared by layout and paint across unchanged active-cell frames.
|
/// One bounded entry shared by layout and paint across unchanged active-cell frames.
|
||||||
pub(super) active_cell_layout: Cell<Option<ActiveCellLayoutCache>>,
|
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>,
|
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)>,
|
pub(super) last_completed_agent_message: Option<(String, String)>,
|
||||||
/// Raw markdown of the most recently completed proposed plan.
|
/// Raw markdown of the most recently completed proposed plan.
|
||||||
pub(super) latest_proposed_plan_markdown: Option<String>,
|
pub(super) latest_proposed_plan_markdown: Option<String>,
|
||||||
@@ -80,13 +82,15 @@ impl TranscriptState {
|
|||||||
active_cell
|
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_markdown = Some(markdown);
|
||||||
|
self.last_agent_source = Some(source);
|
||||||
self.saw_copy_source_this_turn = true;
|
self.saw_copy_source_this_turn = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn reset_copy_history(&mut self) {
|
pub(super) fn reset_copy_history(&mut self) {
|
||||||
self.last_agent_markdown = None;
|
self.last_agent_markdown = None;
|
||||||
|
self.last_agent_source = None;
|
||||||
self.saw_copy_source_this_turn = false;
|
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 visible = String::new();
|
||||||
let mut actions = Vec::new();
|
let mut actions = Vec::new();
|
||||||
let mut remaining = line;
|
let mut remaining = line;
|
||||||
|
|||||||
@@ -19,10 +19,15 @@
|
|||||||
//! buffers the entire fence body before deciding, only unwraps fences whose
|
//! buffers the entire fence body before deciding, only unwraps fences whose
|
||||||
//! info string is `md` or `markdown` AND whose body contains a
|
//! info string is `md` or `markdown` AND whose body contains a
|
||||||
//! header+delimiter pair, and degrades gracefully on unclosed fences.
|
//! 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 ratatui::text::Line;
|
||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::inline_visualization::InlineVisualizationContext;
|
use crate::inline_visualization::InlineVisualizationContext;
|
||||||
use crate::inline_visualization::rewrite_inline_visualizations;
|
use crate::inline_visualization::rewrite_inline_visualizations;
|
||||||
@@ -134,6 +139,93 @@ pub(crate) fn render_streaming_markdown_agent_with_links_and_cwd(
|
|||||||
rendered
|
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
|
/// Strip `` ```md ``/`` ```markdown `` fences that contain tables, emitting their content as bare
|
||||||
/// markdown so `pulldown-cmark` parses the tables natively.
|
/// markdown so `pulldown-cmark` parses the tables natively.
|
||||||
///
|
///
|
||||||
@@ -359,6 +451,68 @@ mod tests {
|
|||||||
use pretty_assertions::assert_eq;
|
use pretty_assertions::assert_eq;
|
||||||
use ratatui::text::Line;
|
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> {
|
fn lines_to_strings(lines: &[Line<'static>]) -> Vec<String> {
|
||||||
lines
|
lines
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -120,8 +120,12 @@ pub(crate) fn maybe_init(config: &Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
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
|
// Log only if enabled
|
||||||
if !LOGGER.is_enabled() {
|
if !logger.is_enabled() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +136,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
|||||||
"dir": "to_tui",
|
"dir": "to_tui",
|
||||||
"kind": "new_session",
|
"kind": "new_session",
|
||||||
});
|
});
|
||||||
LOGGER.write_json_line(value);
|
logger.write_json_line(value);
|
||||||
}
|
}
|
||||||
AppEvent::ClearUi { .. } => {
|
AppEvent::ClearUi { .. } => {
|
||||||
let value = json!({
|
let value = json!({
|
||||||
@@ -140,7 +144,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
|||||||
"dir": "to_tui",
|
"dir": "to_tui",
|
||||||
"kind": "clear_ui",
|
"kind": "clear_ui",
|
||||||
});
|
});
|
||||||
LOGGER.write_json_line(value);
|
logger.write_json_line(value);
|
||||||
}
|
}
|
||||||
AppEvent::InsertHistoryCell(cell) => {
|
AppEvent::InsertHistoryCell(cell) => {
|
||||||
let value = json!({
|
let value = json!({
|
||||||
@@ -149,7 +153,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
|||||||
"kind": "insert_history_cell",
|
"kind": "insert_history_cell",
|
||||||
"lines": cell.transcript_lines(u16::MAX).len(),
|
"lines": cell.transcript_lines(u16::MAX).len(),
|
||||||
});
|
});
|
||||||
LOGGER.write_json_line(value);
|
logger.write_json_line(value);
|
||||||
}
|
}
|
||||||
AppEvent::StartFileSearch(query) => {
|
AppEvent::StartFileSearch(query) => {
|
||||||
let value = json!({
|
let value = json!({
|
||||||
@@ -158,7 +162,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
|||||||
"kind": "file_search_start",
|
"kind": "file_search_start",
|
||||||
"query": query,
|
"query": query,
|
||||||
});
|
});
|
||||||
LOGGER.write_json_line(value);
|
logger.write_json_line(value);
|
||||||
}
|
}
|
||||||
AppEvent::FileSearchResult { query, matches } => {
|
AppEvent::FileSearchResult { query, matches } => {
|
||||||
let value = json!({
|
let value = json!({
|
||||||
@@ -168,7 +172,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
|||||||
"query": query,
|
"query": query,
|
||||||
"matches": matches.len(),
|
"matches": matches.len(),
|
||||||
});
|
});
|
||||||
LOGGER.write_json_line(value);
|
logger.write_json_line(value);
|
||||||
}
|
}
|
||||||
AppEvent::PetPreviewLoaded { request_id, result } => {
|
AppEvent::PetPreviewLoaded { request_id, result } => {
|
||||||
let value = json!({
|
let value = json!({
|
||||||
@@ -179,7 +183,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
|||||||
"request_id": request_id,
|
"request_id": request_id,
|
||||||
"ok": result.is_ok(),
|
"ok": result.is_ok(),
|
||||||
});
|
});
|
||||||
LOGGER.write_json_line(value);
|
logger.write_json_line(value);
|
||||||
}
|
}
|
||||||
AppEvent::PetSelectionLoaded {
|
AppEvent::PetSelectionLoaded {
|
||||||
request_id,
|
request_id,
|
||||||
@@ -195,7 +199,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
|||||||
"pet_id": pet_id,
|
"pet_id": pet_id,
|
||||||
"ok": result.is_ok(),
|
"ok": result.is_ok(),
|
||||||
});
|
});
|
||||||
LOGGER.write_json_line(value);
|
logger.write_json_line(value);
|
||||||
}
|
}
|
||||||
// Noise or control flow – record variant only
|
// Noise or control flow – record variant only
|
||||||
other => {
|
other => {
|
||||||
@@ -206,7 +210,7 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
|||||||
"kind": "app_event",
|
"kind": "app_event",
|
||||||
"variant": variant,
|
"variant": variant,
|
||||||
});
|
});
|
||||||
LOGGER.write_json_line(value);
|
logger.write_json_line(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,3 +246,7 @@ where
|
|||||||
});
|
});
|
||||||
LOGGER.write_json_line(value);
|
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::Fork => "fork the current chat",
|
||||||
SlashCommand::App => "continue this session in the Desktop app",
|
SlashCommand::App => "continue this session in the Desktop app",
|
||||||
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
|
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::Export => "export the conversation as markdown",
|
||||||
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
|
SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection",
|
||||||
SlashCommand::Diff => "show git diff (including untracked files)",
|
SlashCommand::Diff => "show git diff (including untracked files)",
|
||||||
|
|||||||
Reference in new issue
Block a user