Skip to content
Open
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
57 changes: 57 additions & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,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;
Expand Down Expand Up @@ -512,6 +513,12 @@ pub(crate) enum ExternalEditorState {
/// Quit/interrupt behavior intentionally spans layers: the bottom pane owns local input routing
/// (which view gets Ctrl+C), while `ChatWidget` owns process-level decisions such as interrupting
/// active work, arming the double-press quit shortcut, and requesting shutdown-first exit.
type ClipboardTextWriter = Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync + 'static>;

fn copy_text_to_clipboard(text: &str) -> Result<(), String> {
clipboard_text::copy_text_to_clipboard(text).map_err(|e| e.to_string())
}

pub(crate) struct ChatWidget {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>,
Expand Down Expand Up @@ -550,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>,
Expand Down Expand Up @@ -662,6 +671,7 @@ pub(crate) struct ChatWidget {
// True once we've attempted a branch lookup for the current CWD.
status_line_branch_lookup_complete: bool,
external_editor_state: ExternalEditorState,
clipboard_text_writer: ClipboardTextWriter,
}

/// Snapshot of active-cell state that affects transcript overlay rendering.
Expand Down Expand Up @@ -1077,6 +1087,7 @@ impl ChatWidget {
self.current_rollout_path = event.rollout_path.clone();
self.current_cwd = Some(event.cwd.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);
Expand Down Expand Up @@ -1265,6 +1276,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();
Expand Down Expand Up @@ -1358,6 +1372,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()
Expand Down Expand Up @@ -2685,6 +2704,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,
Expand Down Expand Up @@ -2735,6 +2755,7 @@ impl ChatWidget {
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
external_editor_state: ExternalEditorState::Closed,
clipboard_text_writer: Arc::new(copy_text_to_clipboard),
};

widget.prefetch_rate_limits();
Expand Down Expand Up @@ -2855,6 +2876,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,
Expand Down Expand Up @@ -2905,6 +2927,7 @@ impl ChatWidget {
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
external_editor_state: ExternalEditorState::Closed,
clipboard_text_writer: Arc::new(copy_text_to_clipboard),
};

widget.prefetch_rate_limits();
Expand Down Expand Up @@ -3014,6 +3037,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,
Expand Down Expand Up @@ -3064,6 +3088,7 @@ impl ChatWidget {
status_line_branch_pending: false,
status_line_branch_lookup_complete: false,
external_editor_state: ExternalEditorState::Closed,
clipboard_text_writer: Arc::new(copy_text_to_clipboard),
};

widget.prefetch_rate_limits();
Expand Down Expand Up @@ -3467,6 +3492,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 = (self.clipboard_text_writer)(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("@");
}
Expand Down Expand Up @@ -4199,6 +4252,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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 4513
expression: rendered.clone()
---
Failed to copy to clipboard: simulated clipboard failure
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 4476
expression: rendered
---
Copied latest Codex output to clipboard. Current turn is still running; copied the latest completed output (not the in-progress response).
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 4363
expression: rendered.clone()
---
Copied latest Codex output to clipboard.
Loading
Loading