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