Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions codex-rs/tui/src/app/event_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
71 changes: 71 additions & 0 deletions codex-rs/tui/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 0 additions & 7 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions codex-rs/tui/src/chatwidget/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/tui/src/chatwidget/slash_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 10 additions & 6 deletions codex-rs/tui/src/chatwidget/streaming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading