From 52e73e3a548ae5310c7765995b9803dd538b82b0 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Sat, 5 Sep 2026 16:32:15 +0000 Subject: [PATCH] Allow `/copy` to copy status output and individual fields (#43055) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- ...p__tests__command_popup_default_items.snap | 2 +- codex-rs/tui/src/chatwidget.rs | 2 + codex-rs/tui/src/chatwidget/interaction.rs | 103 ++++++----- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 10 ++ ...ests__slash_copy_picker_status_fields.snap | 14 ++ ...idget__tests__slash_copy_whole_status.snap | 20 +++ .../tui/src/chatwidget/status_controls.rs | 20 ++- .../src/chatwidget/tests/slash_commands.rs | 169 ++++++++++++++++++ .../chatwidget/tests/status_command_tests.rs | 9 + codex-rs/tui/src/chatwidget/transcript.rs | 10 ++ codex-rs/tui/src/slash_command.rs | 2 +- codex-rs/tui/src/status/card.rs | 85 +++++---- 12 files changed, 367 insertions(+), 79 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_picker_status_fields.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_whole_status.snap diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap index 8bbb6892df..280a204be2 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap @@ -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) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 300ad73dbe..399615f267 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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 { diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index f111064259..5b0eedec52 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -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::::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::::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::::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() diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 8ec4614cd1..c7dec771fb 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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, ) { + 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, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_picker_status_fields.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_picker_status_fields.snap new file mode 100644 index 0000000000..4a4416af9c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_picker_status_fields.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_whole_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_whole_status.snap new file mode 100644 index 0000000000..f1feda58ba --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_copy_whole_status.snap @@ -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: + 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 diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index db9c3f9411..0666076ee7 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -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::::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( diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 07a3444767..71dc31a3bb 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -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] { diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index 25f3e83a37..20aeac42dd 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -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)) => { diff --git a/codex-rs/tui/src/chatwidget/transcript.rs b/codex-rs/tui/src/chatwidget/transcript.rs index 2268800255..67b506ac28 100644 --- a/codex-rs/tui/src/chatwidget/transcript.rs +++ b/codex-rs/tui/src/chatwidget/transcript.rs @@ -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, } +pub(super) struct StatusCopySource { + pub(super) handle: crate::status::StatusHistoryHandle, + pub(super) fields: Vec<(String, Arc)>, +} + #[derive(Default)] pub(super) struct TranscriptState { pub(super) active_cell: Option>, @@ -36,6 +42,8 @@ pub(super) struct TranscriptState { pub(super) last_agent_markdown: Option, /// Original source of that response, before display sanitization, for exact block copying. pub(super) last_agent_source: Option, + /// Latest `/status` card and fields, retained across copying until a later turn or command. + pub(super) last_status_copy_targets: Option, 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, @@ -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; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index aab133ffb7..97b4b9897b 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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)", diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 34dc548d51..bd4f557b9d 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -79,13 +79,23 @@ struct StatusRateLimitState { #[derive(Debug, Clone)] pub(crate) struct StatusHistoryHandle { - rate_limit_state: Arc>, - thread_usage: StatusThreadUsage, + card: Arc, } 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::>() + .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, ) { - 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>, 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> { @@ -726,8 +734,8 @@ fn status_approval_label( approval.to_string() } -impl HistoryCell for StatusHistoryCell { - fn display_lines(&self, width: u16) -> Vec> { +impl StatusHistoryCell { + fn content_lines(&self, width: u16) -> Vec> { let mut lines: Vec> = 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 { + fn display_lines(&self, width: u16) -> Vec> { + 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> = lines