Allow /copy to copy status output and individual fields (#43055)

## Why

`/copy` only offered the last assistant response and its code blocks or quotes, leaving `/status` output unavailable in the copy picker.

## What changed

After `/status`, offer the whole status as plain text, the model, the full directory path, and the thread name and session ID when present. Preserve the displayed status fields across repeated copies and include refreshed rate limits in whole-status copies.

Return to assistant-response copying after a later user message, assistant response, or command other than `/copy`. Rename the picker to “Copy to clipboard”.

## Testing

Add snapshots and regression tests covering status text and field selection, missing fields, full directory paths, repeated copying, refreshed limits, and returning to response copying after subsequent turns and commands, including queued commands.

GitOrigin-RevId: 9424cdca9753c834971992ca29494161957b50c4
This commit is contained in:
Benjamin Carlsson
2026-09-05 16:43:40 +00:00
committed by copyberry
parent 89208f09f8
commit 52e73e3a54
12 changed files with 367 additions and 79 deletions
@@ -26,7 +26,7 @@ expression: commands
/recap - summarize the current conversation now
/agents - view and switch between all active agent sessions
/side - start a side conversation in an ephemeral fork
/copy - copy the last response, code block, or quote
/copy - copy the last response or part of it
/export - export the conversation as markdown
/raw - toggle raw scrollback mode for copy-friendly terminal selection
/diff - show git diff (including untracked files)
+2
View File
@@ -1374,6 +1374,7 @@ impl ChatWidget {
}
fn on_user_message_display(&mut self, display: UserMessageDisplay) {
self.transcript.last_status_copy_targets = None;
self.last_rendered_user_message_display = Some(display.clone());
if !display.message.trim().is_empty()
|| !display.text_elements.is_empty()
@@ -1842,6 +1843,7 @@ impl ChatWidget {
| AppCommand::Review { .. }
| AppCommand::RunUserShellCommand { .. }
) {
self.transcript.last_status_copy_targets = None;
self.input_queue.user_turn_pending_start = true;
}
if matches!(op, AppCommand::Interrupt) && self.turn_lifecycle.agent_turn_running {
+60 -43
View File
@@ -320,53 +320,70 @@ impl ChatWidget {
}
pub(super) fn show_copy_picker(&mut self) {
let Some(markdown) = self
let mut choices = Vec::new();
if let Some(status_targets) = &self.transcript.last_status_copy_targets {
choices.push((
"Whole status".to_string(),
Arc::<str>::from(status_targets.handle.copy_text()),
CopyFormat::PlainText,
));
choices.extend(
status_targets
.fields
.iter()
.cloned()
.map(|(label, text)| (label, text, CopyFormat::PlainText)),
);
} else if let Some(markdown) = self
.transcript
.last_agent_markdown
.clone()
.as_deref()
.filter(|markdown| !markdown.is_empty())
else {
{
choices.push((
"Whole response".to_string(),
Arc::<str>::from(markdown),
CopyFormat::Markdown,
));
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,
CopyFormat::PlainText,
)),
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),
CopyFormat::PlainText,
)
})
}
}),
);
}
if choices.is_empty() {
self.copy_last_agent_markdown();
return;
};
let mut choices = vec![(
"Whole response".to_string(),
Arc::<str>::from(markdown.as_str()),
CopyFormat::Markdown,
)];
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,
CopyFormat::PlainText,
)),
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),
CopyFormat::PlainText,
)
})
}
}),
);
}
let items = choices
.into_iter()
@@ -392,7 +409,7 @@ impl ChatWidget {
.collect();
self.show_selection_view(SelectionViewParams {
title: Some("Copy from response".to_string()),
title: Some("Copy to clipboard".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
@@ -53,6 +53,7 @@ impl ChatWidget {
}
pub(super) fn handle_service_tier_command_dispatch(&mut self, command: ServiceTierCommand) {
self.transcript.last_status_copy_targets = None;
if self.active_side_conversation {
self.add_error_message(format!(
"'/{}' is unavailable in side conversations. {SIDE_SLASH_COMMAND_UNAVAILABLE_HINT}",
@@ -145,6 +146,9 @@ impl ChatWidget {
}
pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) {
if cmd != SlashCommand::Copy {
self.transcript.last_status_copy_targets = None;
}
if !self.ensure_slash_command_allowed_in_side_conversation(cmd) {
return;
}
@@ -596,6 +600,9 @@ impl ChatWidget {
args: String,
text_elements: Vec<TextElement>,
) {
if cmd != SlashCommand::Copy {
self.transcript.last_status_copy_targets = None;
}
if !self.ensure_slash_command_allowed_in_side_conversation(cmd) {
return;
}
@@ -710,6 +717,9 @@ impl ChatWidget {
cmd: SlashCommand,
prepared: PreparedSlashCommandArgs,
) {
if cmd != SlashCommand::Copy {
self.transcript.last_status_copy_targets = None;
}
let PreparedSlashCommandArgs {
args,
text_elements,
@@ -0,0 +1,14 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
assertion_line: 2032
expression: "render_bottom_popup(&chat,\n100).replace(&directory,\n\"[[workspace]]\").replace(crate::version::CODEX_CLI_VERSION, \"VERSION\")"
---
Copy to clipboard
› 1. Whole status >_ OpenAI Codex (vVERSION)
2. Model gpt-5.4
3. Directory [[workspace]]
4. Thread name Clipboard example
5. Session ID 00000000-0000-0000-0000-000000000123
Press enter to confirm or esc to go back
@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
assertion_line: 2041
expression: "whole_status.replace(&directory,\n\"[[workspace]]\").replace(crate::version::CODEX_CLI_VERSION, \"VERSION\")"
---
>_ OpenAI Codex (vVERSION)
Visit https://chatgpt.com/codex/settings/usage for up-to-date
information on rate limits and credits
Model: gpt-5.4 (reasoning medium, summaries auto)
Directory: [[workspace]]
Permissions: Read Only (Ask for approval)
Agents.md: <none>
Thread name: Clipboard example
Collaboration mode: Default
Session: 00000000-0000-0000-0000-000000000123
Token usage: 0 total (0 input + 0 output)
Limits: data not available yet
+19 -1
View File
@@ -268,10 +268,28 @@ impl ChatWidget {
handle.reserve_thread_usage_label_width();
handle.set_thread_usage(self.estimated_thread_usage().cloned());
self.add_to_history(cell);
self.request_thread_usage_for_status(handle);
self.request_thread_usage_for_status(handle.clone());
} else {
self.add_to_history(cell);
}
// Capture the displayed status inputs before later configuration or thread changes.
let mut copy_targets = vec![
("Model".to_string(), Arc::<str>::from(model)),
(
"Directory".to_string(),
Arc::from(self.config.cwd.display().to_string()),
),
];
if let Some(name) = self.thread_name.as_deref().filter(|name| !name.is_empty()) {
copy_targets.push(("Thread name".to_string(), Arc::from(name)));
}
if let Some(thread_id) = self.thread_id {
copy_targets.push(("Session ID".to_string(), Arc::from(thread_id.to_string())));
}
self.transcript.last_status_copy_targets = Some(super::transcript::StatusCopySource {
handle,
fields: copy_targets,
});
}
pub(crate) fn finish_status_rate_limit_refresh(
@@ -2014,6 +2014,175 @@ async fn slash_copy_picker_previews_whole_response_code_blocks_and_blockquotes()
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn slash_copy_picker_copies_status_fields_and_preserves_source_after_copying() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
let session_id = "00000000-0000-0000-0000-000000000123";
chat.thread_id = Some(ThreadId::from_string(session_id).expect("valid thread ID"));
chat.thread_name = Some("Clipboard example".to_string());
chat.config.cwd = test_path_buf("/workspace").abs();
let directory = chat.config.cwd.display().to_string();
chat.dispatch_command(SlashCommand::Status);
drain_insert_history(&mut rx);
// The picker must copy the status that was displayed, not newer widget settings.
chat.config.cwd = test_path_buf("/another-workspace").abs();
chat.thread_name = Some("New name".to_string());
chat.dispatch_command(SlashCommand::Copy);
assert_chatwidget_snapshot!(
"slash_copy_picker_status_fields",
render_bottom_popup(&chat, /*width*/ 100)
.replace(&directory, "[[workspace]]")
.replace(crate::version::CODEX_CLI_VERSION, "VERSION"),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let (whole_status, label) = next_copy_selection(&mut rx);
assert_eq!(label, "Whole status");
assert_chatwidget_snapshot!(
"slash_copy_whole_status",
whole_status
.replace(&directory, "[[workspace]]")
.replace(crate::version::CODEX_CLI_VERSION, "VERSION"),
);
let expected = [
("Whole status", whole_status.as_str()),
("Model", "gpt-5.4"),
("Directory", directory.as_str()),
("Thread name", "Clipboard example"),
("Session ID", session_id),
];
for (index, (label, value)) in expected.into_iter().enumerate() {
chat.dispatch_command(SlashCommand::Copy);
let shortcut =
char::from_digit((index + 1) as u32, /*radix*/ 10).expect("numeric shortcut");
chat.handle_key_event(KeyEvent::new(KeyCode::Char(shortcut), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
(value.to_string(), label.to_string())
);
chat.copy_selection_with(value, label, |text| {
assert_eq!(text, value);
Ok(None)
});
drain_insert_history(&mut rx);
assert!(chat.bottom_pane.no_modal_or_popup_active());
}
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
}
#[tokio::test]
async fn slash_copy_status_omits_missing_fields_and_copies_full_directory() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.config.cwd = test_path_buf(&format!("/workspace/{}", "nested/".repeat(/*n*/ 20))).abs();
let directory = chat.config.cwd.display().to_string();
chat.thread_name = Some(String::new());
chat.dispatch_command(SlashCommand::Status);
drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Copy);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(popup.contains("2. Model"));
assert!(popup.contains("3. Directory"));
assert!(!popup.contains("Thread name"));
assert!(!popup.contains("Session ID"));
chat.handle_key_event(KeyEvent::new(KeyCode::Char('3'), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
(directory, "Directory".to_string())
);
}
#[tokio::test]
async fn slash_copy_status_yields_to_later_turns_and_commands_even_after_refresh() {
enum NextTurn {
Response,
UserTurn,
Command,
InlineCommand,
RejectedInlineCommand,
ServiceTier,
ShellCommand,
}
for next in [
NextTurn::Response,
NextTurn::UserTurn,
NextTurn::Command,
NextTurn::InlineCommand,
NextTurn::RejectedInlineCommand,
NextTurn::ServiceTier,
NextTurn::ShellCommand,
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.thread_id = Some(ThreadId::new());
complete_turn_with_message(&mut chat, "turn-1", Some("Original reply"));
set_chatgpt_auth(&mut chat);
chat.dispatch_command(SlashCommand::Status);
drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Copy);
assert!(render_bottom_popup(&chat, /*width*/ 80).contains("1. Whole status"));
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
drain_insert_history(&mut rx);
match next {
NextTurn::Response => {
complete_turn_with_message(&mut chat, "turn-2", Some("Next reply"))
}
NextTurn::UserTurn => chat.submit_user_message(UserMessage::from("Next question")),
NextTurn::Command => chat.dispatch_command(SlashCommand::Pwd),
NextTurn::InlineCommand => chat.dispatch_command_with_args(
SlashCommand::Rename,
"Renamed thread".to_string(),
Vec::new(),
),
NextTurn::RejectedInlineCommand => {
handle_turn_started(&mut chat, "turn-2");
chat.dispatch_command_with_args(SlashCommand::Cd, "/tmp".to_string(), Vec::new());
}
NextTurn::ServiceTier => chat.handle_service_tier_command_dispatch(fast_tier_command()),
NextTurn::ShellCommand => chat.submit_user_message(UserMessage::from("!echo hello")),
}
chat.finish_status_rate_limit_refresh(/*request_id*/ 0, Vec::new());
drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Copy);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
let expected = if matches!(next, NextTurn::Response) {
"Next reply"
} else {
"Original reply"
};
assert_eq!(
next_copy_selection(&mut rx),
(expected.to_string(), "Whole response".to_string())
);
}
}
#[tokio::test]
async fn slash_copy_status_yields_to_queued_commands_after_queued_status() {
for command in ["/rename Queued rename", "!echo hello"] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
handle_turn_started(&mut chat, "turn-1");
queue_composer_text_with_tab(&mut chat, "/status");
queue_composer_text_with_tab(&mut chat, command);
complete_turn_with_message(&mut chat, "turn-1", Some("Original reply"));
let history = drain_insert_history(&mut rx);
assert!(
history
.iter()
.any(|lines| lines_to_single_string(lines).contains("/status"))
);
assert!(chat.input_queue.queued_user_messages.is_empty());
chat.dispatch_command(SlashCommand::Copy);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
assert_eq!(
next_copy_selection(&mut rx),
("Original reply".to_string(), "Whole response".to_string())
);
}
}
#[tokio::test]
async fn slash_copy_picker_waits_for_submission_after_typing_or_autocomplete() {
for select_from_autocomplete in [false, true] {
@@ -52,6 +52,15 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs(
chat.finish_status_rate_limit_refresh(first_request_id, vec![snapshot(/*percent*/ 92.0)]);
drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Copy);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::CopySelection { text, label, .. })
if label == "Whole status" && text.contains("8% left")
);
assert_matches!(rx.try_recv(), Ok(AppEvent::SettingsSelectionClosed));
chat.dispatch_command(SlashCommand::Status);
let refreshed = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => {
+10
View File
@@ -3,6 +3,7 @@
use super::HistoryCell;
use super::HistoryRenderMode;
use std::cell::Cell;
use std::sync::Arc;
/// Identifies the render state that determines an active cell's viewport height.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -25,6 +26,11 @@ pub(super) struct ActiveCellLayoutCache {
pub(super) rendered_height: Option<usize>,
}
pub(super) struct StatusCopySource {
pub(super) handle: crate::status::StatusHistoryHandle,
pub(super) fields: Vec<(String, Arc<str>)>,
}
#[derive(Default)]
pub(super) struct TranscriptState {
pub(super) active_cell: Option<Box<dyn HistoryCell>>,
@@ -36,6 +42,8 @@ pub(super) struct TranscriptState {
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>,
/// Latest `/status` card and fields, retained across copying until a later turn or command.
pub(super) last_status_copy_targets: Option<StatusCopySource>,
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>,
@@ -83,12 +91,14 @@ impl TranscriptState {
}
pub(super) fn record_agent_markdown(&mut self, markdown: String, source: String) {
self.last_status_copy_targets = None;
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_status_copy_targets = None;
self.last_agent_markdown = None;
self.last_agent_source = None;
self.saw_copy_source_this_turn = false;
+1 -1
View File
@@ -101,7 +101,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 the last response, code block, or quote",
SlashCommand::Copy => "copy the last response or part of it",
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)",
+52 -33
View File
@@ -79,13 +79,23 @@ struct StatusRateLimitState {
#[derive(Debug, Clone)]
pub(crate) struct StatusHistoryHandle {
rate_limit_state: Arc<RwLock<StatusRateLimitState>>,
thread_usage: StatusThreadUsage,
card: Arc<StatusHistoryCell>,
}
impl StatusHistoryHandle {
pub(crate) fn copy_text(&self) -> String {
self.card
.content_lines(u16::MAX)
.iter()
.map(|line| line.to_string().trim_end().to_string())
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
pub(crate) fn reserve_thread_usage_label_width(&self) {
self.thread_usage.reserve_label_width();
self.card.thread_usage.reserve_label_width();
}
pub(crate) fn finish_rate_limit_refresh(
@@ -100,6 +110,7 @@ impl StatusHistoryHandle {
};
#[expect(clippy::expect_used)]
let mut state = self
.card
.rate_limit_state
.write()
.expect("status history rate-limit state poisoned");
@@ -111,7 +122,7 @@ impl StatusHistoryHandle {
&self,
estimate: Option<codex_app_server_protocol::ThreadUsage>,
) {
self.thread_usage.set_estimate(estimate);
self.card.thread_usage.set_estimate(estimate);
}
}
@@ -234,7 +245,7 @@ pub(crate) fn new_status_output_with_rate_limits_handle(
refreshing_rate_limits: bool,
) -> (CompositeHistoryCell, StatusHistoryHandle) {
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
let (card, handle) = StatusHistoryCell::new(
let card = Arc::new(StatusHistoryCell::new(
config,
requires_openai_auth,
runtime_model_provider_base_url,
@@ -253,7 +264,10 @@ pub(crate) fn new_status_output_with_rate_limits_handle(
reasoning_effort_override,
agents_summary,
refreshing_rate_limits,
);
));
let handle = StatusHistoryHandle {
card: Arc::clone(&card),
};
(
CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]),
@@ -282,7 +296,7 @@ impl StatusHistoryCell {
reasoning_effort_override: Option<Option<ReasoningEffort>>,
agents_summary: String,
refreshing_rate_limits: bool,
) -> (Self, StatusHistoryHandle) {
) -> Self {
let approval_policy = AskForApproval::from(config.permissions.approval_policy.value());
let permission_profile = config.permissions.effective_permission_profile();
let workspace_roots = config.effective_workspace_roots();
@@ -370,30 +384,24 @@ impl StatusHistoryCell {
let agents_summary = Arc::new(RwLock::new(agents_summary));
let thread_usage = StatusThreadUsage::default();
(
Self {
model_name,
model_details,
directory: config.cwd.to_path_buf(),
permissions,
collaboration_mode: collaboration_mode.map(ToString::to_string),
model_provider,
remote_connection: remote_connection.cloned(),
show_chatgpt_usage_link,
account,
thread_name,
session_id,
forked_from,
token_usage,
agents_summary,
rate_limit_state: rate_limit_state.clone(),
thread_usage: thread_usage.clone(),
},
StatusHistoryHandle {
rate_limit_state,
thread_usage,
},
)
Self {
model_name,
model_details,
directory: config.cwd.to_path_buf(),
permissions,
collaboration_mode: collaboration_mode.map(ToString::to_string),
model_provider,
remote_connection: remote_connection.cloned(),
show_chatgpt_usage_link,
account,
thread_name,
session_id,
forked_from,
token_usage,
agents_summary,
rate_limit_state,
thread_usage,
}
}
fn token_usage_spans(&self) -> Vec<Span<'static>> {
@@ -726,8 +734,8 @@ fn status_approval_label(
approval.to_string()
}
impl HistoryCell for StatusHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
impl StatusHistoryCell {
fn content_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(vec![
Span::from(format!("{}>_ ", FieldFormatter::INDENT)).dim(),
@@ -889,6 +897,17 @@ impl HistoryCell for StatusHistoryCell {
lines.extend(thread_usage_lines);
}
lines
}
}
impl HistoryCell for Arc<StatusHistoryCell> {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let available_inner_width = usize::from(width.saturating_sub(4));
if available_inner_width == 0 {
return Vec::new();
}
let lines = self.content_lines(width);
let content_width = lines.iter().map(line_width).max().unwrap_or(0);
let inner_width = content_width.min(available_inner_width);
let truncated_lines: Vec<Line<'static>> = lines