feat(tui) - /copy (#12613)

# /copy!

/copy allows you to copy the latest **complete** message from Codex on
the TUI.
This commit is contained in:
Won Park
2026-02-24 14:17:01 -08:00
committed by GitHub
Unverified
parent 61cd3a9700
commit ee1520e79e
6 changed files with 267 additions and 0 deletions
+47
View File
@@ -218,6 +218,7 @@ use crate::bottom_pane::SelectionViewParams;
use crate::bottom_pane::custom_prompt_view::CustomPromptView;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
use crate::clipboard_paste::paste_image_to_temp_png;
use crate::clipboard_text;
use crate::collaboration_modes;
use crate::diff_render::display_path_for;
use crate::exec_cell::CommandOutput;
@@ -556,6 +557,8 @@ pub(crate) struct ChatWidget {
stream_controller: Option<StreamController>,
// Stream lifecycle controller for proposed plan output.
plan_stream_controller: Option<PlanStreamController>,
// Latest completed user-visible Codex output that `/copy` should place on the clipboard.
last_copyable_output: Option<String>,
running_commands: HashMap<String, RunningCommand>,
suppressed_exec_calls: HashSet<String>,
skills_all: Vec<ProtocolSkillMetadata>,
@@ -1123,6 +1126,7 @@ impl ChatWidget {
Constrained::allow_only(event.sandbox_policy.clone());
}
let initial_messages = event.initial_messages.clone();
self.last_copyable_output = None;
let forked_from_id = event.forked_from_id;
let model_for_header = event.model.clone();
self.session_header.set_model(&model_for_header);
@@ -1311,6 +1315,9 @@ impl ChatWidget {
} else {
text
};
if !plan_text.trim().is_empty() {
self.last_copyable_output = Some(plan_text.clone());
}
// Plan commit ticks can hide the status row; remember whether we streamed plan output so
// completion can restore it once stream queues are idle.
let should_restore_after_stream = self.plan_stream_controller.is_some();
@@ -1404,6 +1411,11 @@ impl ChatWidget {
}
fn on_task_complete(&mut self, last_agent_message: Option<String>, from_replay: bool) {
if let Some(message) = last_agent_message.as_ref()
&& !message.trim().is_empty()
{
self.last_copyable_output = Some(message.clone());
}
// If a stream is currently active, finalize it.
self.flush_answer_stream_with_separator();
if let Some(mut controller) = self.plan_stream_controller.take()
@@ -2807,6 +2819,7 @@ impl ChatWidget {
adaptive_chunking: AdaptiveChunkingPolicy::default(),
stream_controller: None,
plan_stream_controller: None,
last_copyable_output: None,
running_commands: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
@@ -2983,6 +2996,7 @@ impl ChatWidget {
adaptive_chunking: AdaptiveChunkingPolicy::default(),
stream_controller: None,
plan_stream_controller: None,
last_copyable_output: None,
running_commands: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
@@ -3148,6 +3162,7 @@ impl ChatWidget {
adaptive_chunking: AdaptiveChunkingPolicy::default(),
stream_controller: None,
plan_stream_controller: None,
last_copyable_output: None,
running_commands: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
last_unified_wait: None,
@@ -3650,6 +3665,34 @@ impl ChatWidget {
tx.send(AppEvent::DiffResult(text));
});
}
SlashCommand::Copy => {
let Some(text) = self.last_copyable_output.as_deref() else {
self.add_info_message(
"`/copy` is unavailable before the first Codex output or right after a rollback."
.to_string(),
None,
);
return;
};
let copy_result = clipboard_text::copy_text_to_clipboard(text);
match copy_result {
Ok(()) => {
let hint = self.agent_turn_running.then_some(
"Current turn is still running; copied the latest completed output (not the in-progress response)."
.to_string(),
);
self.add_info_message(
"Copied latest Codex output to clipboard.".to_string(),
hint,
);
}
Err(err) => {
self.add_error_message(format!("Failed to copy to clipboard: {err}"))
}
}
}
SlashCommand::Mention => {
self.insert_str("@");
}
@@ -4402,6 +4445,10 @@ impl ChatWidget {
EventMsg::CollabResumeBegin(ev) => self.on_collab_event(multi_agents::resume_begin(ev)),
EventMsg::CollabResumeEnd(ev) => self.on_collab_event(multi_agents::resume_end(ev)),
EventMsg::ThreadRolledBack(rollback) => {
// Conservatively clear `/copy` state on rollback. The app layer trims visible
// transcript cells, but we do not maintain rollback-aware raw-markdown history yet,
// so keeping the previous cache can return content that was just removed.
self.last_copyable_output = None;
if from_replay {
self.app_event_tx.send(AppEvent::ApplyThreadRollback {
num_turns: rollback.num_turns,
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 4430
expression: rendered.clone()
---
• `/copy` is unavailable before the first Codex output or right after a rollback.
+198
View File
@@ -37,6 +37,7 @@ use codex_protocol::config_types::Personality;
use codex_protocol::config_types::Settings;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::PlanItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::openai_models::ModelPreset;
@@ -1667,6 +1668,7 @@ async fn make_chatwidget_manual(
adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(),
stream_controller: None,
plan_stream_controller: None,
last_copyable_output: None,
running_commands: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
skills_all: Vec::new(),
@@ -4624,6 +4626,202 @@ async fn slash_quit_requests_exit() {
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
}
#[tokio::test]
async fn slash_copy_state_tracks_turn_complete_final_reply() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Final reply **markdown**".to_string()),
}),
});
assert_eq!(
chat.last_copyable_output,
Some("Final reply **markdown**".to_string())
);
}
#[tokio::test]
async fn slash_copy_state_tracks_plan_item_completion() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let plan_text = "## Plan\n\n1. Build it\n2. Test it".to_string();
chat.handle_codex_event(Event {
id: "item-plan".into(),
msg: EventMsg::ItemCompleted(ItemCompletedEvent {
thread_id: ThreadId::new(),
turn_id: "turn-1".to_string(),
item: TurnItem::Plan(PlanItem {
id: "plan-1".to_string(),
text: plan_text.clone(),
}),
}),
});
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
}),
});
assert_eq!(chat.last_copyable_output, Some(plan_text));
}
#[tokio::test]
async fn slash_copy_reports_when_no_copyable_output_exists() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Copy);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one info message");
let rendered = lines_to_single_string(&cells[0]);
assert_snapshot!("slash_copy_no_output_info_message", rendered);
assert!(
rendered.contains(
"`/copy` is unavailable before the first Codex output or right after a rollback."
),
"expected no-output message, got {rendered:?}"
);
}
#[tokio::test]
async fn slash_copy_state_is_preserved_during_running_task() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Previous completed reply".to_string()),
}),
});
chat.on_task_started();
assert_eq!(
chat.last_copyable_output,
Some("Previous completed reply".to_string())
);
}
#[tokio::test]
async fn slash_copy_state_clears_on_thread_rollback() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Reply that will be rolled back".to_string()),
}),
});
chat.handle_codex_event(Event {
id: "rollback-1".into(),
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
});
assert_eq!(chat.last_copyable_output, None);
}
#[tokio::test]
async fn slash_copy_is_unavailable_when_legacy_agent_message_is_not_repeated_on_turn_complete() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Legacy final message".into(),
phase: None,
}),
});
let _ = drain_insert_history(&mut rx);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
}),
});
let _ = drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Copy);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one info message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains(
"`/copy` is unavailable before the first Codex output or right after a rollback."
),
"expected unavailable message, got {rendered:?}"
);
}
#[tokio::test]
async fn slash_copy_is_unavailable_when_legacy_agent_message_item_is_not_repeated_on_turn_complete()
{
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
complete_assistant_message(&mut chat, "msg-1", "Legacy item final message", None);
let _ = drain_insert_history(&mut rx);
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: None,
}),
});
let _ = drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Copy);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one info message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains(
"`/copy` is unavailable before the first Codex output or right after a rollback."
),
"expected unavailable message, got {rendered:?}"
);
}
#[tokio::test]
async fn slash_copy_does_not_return_stale_output_after_thread_rollback() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
chat.handle_codex_event(Event {
id: "turn-1".into(),
msg: EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-1".to_string(),
last_agent_message: Some("Reply that will be rolled back".to_string()),
}),
});
let _ = drain_insert_history(&mut rx);
chat.handle_codex_event(Event {
id: "rollback-1".into(),
msg: EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }),
});
let _ = drain_insert_history(&mut rx);
chat.dispatch_command(SlashCommand::Copy);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one info message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains(
"`/copy` is unavailable before the first Codex output or right after a rollback."
),
"expected rollback-cleared copy state message, got {rendered:?}"
);
}
#[tokio::test]
async fn slash_exit_requests_exit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
+11
View File
@@ -0,0 +1,11 @@
#[cfg(not(target_os = "android"))]
pub fn copy_text_to_clipboard(text: &str) -> Result<(), String> {
let mut cb = arboard::Clipboard::new().map_err(|e| format!("clipboard unavailable: {e}"))?;
cb.set_text(text.to_string())
.map_err(|e| format!("clipboard unavailable: {e}"))
}
#[cfg(target_os = "android")]
pub fn copy_text_to_clipboard(_text: &str) -> Result<(), String> {
Err("clipboard text copy is unsupported on Android".into())
}
+1
View File
@@ -65,6 +65,7 @@ mod bottom_pane;
mod chatwidget;
mod cli;
mod clipboard_paste;
mod clipboard_text;
mod collaboration_modes;
mod color;
pub mod custom_terminal;
+4
View File
@@ -33,6 +33,7 @@ pub enum SlashCommand {
Agent,
// Undo,
Diff,
Copy,
Mention,
Status,
DebugConfig,
@@ -74,6 +75,7 @@ impl SlashCommand {
// SlashCommand::Undo => "ask Codex to undo a turn",
SlashCommand::Quit | SlashCommand::Exit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Copy => "copy the latest Codex output to your clipboard",
SlashCommand::Mention => "mention a file",
SlashCommand::Skills => "use skills to improve how Codex performs specific tasks",
SlashCommand::Status => "show current session configuration and token usage",
@@ -145,6 +147,7 @@ impl SlashCommand {
| SlashCommand::MemoryDrop
| SlashCommand::MemoryUpdate => false,
SlashCommand::Diff
| SlashCommand::Copy
| SlashCommand::Rename
| SlashCommand::Mention
| SlashCommand::Skills
@@ -170,6 +173,7 @@ impl SlashCommand {
fn is_visible(self) -> bool {
match self {
SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"),
SlashCommand::Copy => !cfg!(target_os = "android"),
SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions),
_ => true,
}