diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 8a8a17148d..5b2a8c740c 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -741,6 +741,19 @@ pub enum TuiPetAnchor { ScreenBottom, } +/// Right-click text paste when the fullscreen TUI has no selection. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum RightClickPaste { + /// Enable on Windows/WSL/Linux, except recognized terminal-owned paste paths. + #[default] + Auto, + /// Enable on supported local platforms; selection and terminal-owned paste still win. + On, + /// Leave right-click paste to the terminal. + Off, +} + /// When transcript mouse selections are copied on release. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] #[serde(rename_all = "lowercase")] @@ -857,6 +870,12 @@ pub struct Tui { #[serde(default)] pub copy_on_select: CopyOnSelect, + /// Right-click text paste fallback. Defaults to `auto` (Windows/WSL/Linux). + /// `on` also enables macOS; neither mode reads over SSH or in recognized VS Code terminals. + /// This controls the fullscreen fallback, not the terminal's own paste binding. + #[serde(default)] + pub right_click_paste: RightClickPaste, + /// Controls whether the TUI uses the terminal's alternate screen buffer. /// /// - `auto` (default): Use alternate screen. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1e8327a60e..c8ad1a3194 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -3857,6 +3857,32 @@ } ] }, + "RightClickPaste": { + "description": "Right-click text paste when the fullscreen TUI has no selection.", + "oneOf": [ + { + "description": "Enable on Windows/WSL/Linux, except recognized terminal-owned paste paths.", + "enum": [ + "auto" + ], + "type": "string" + }, + { + "description": "Enable on supported local platforms; selection and terminal-owned paste still win.", + "enum": [ + "on" + ], + "type": "string" + }, + { + "description": "Leave right-click paste to the terminal.", + "enum": [ + "off" + ], + "type": "string" + } + ] + }, "RolloutBudgetConfigToml": { "additionalProperties": false, "properties": { @@ -4636,6 +4662,15 @@ "default": null, "description": "Working directory to use when resuming or forking a session. When unset, prompt if the current and session directories differ." }, + "right_click_paste": { + "allOf": [ + { + "$ref": "#/definitions/RightClickPaste" + } + ], + "default": "auto", + "description": "Right-click text paste fallback. Defaults to `auto` (Windows/WSL/Linux). `on` also enables macOS; neither mode reads over SSH or in recognized VS Code terminals. This controls the fullscreen fallback, not the terminal's own paste binding." + }, "screen_reader_detection_done": { "description": "Records the one-time screen-reader detection attempt. Either value skips detection.", "type": "boolean" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 3e7f1f4a86..05e984e688 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1271,6 +1271,7 @@ fn config_toml_deserializes_model_availability_nux() { raw_output_mode: false, fullscreen_transcript: true, copy_on_select: Default::default(), + right_click_paste: Default::default(), alternate_screen: AltScreenMode::default(), status_line: None, status_line_use_colors: true, @@ -4412,6 +4413,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { raw_output_mode: false, fullscreen_transcript: true, copy_on_select: Default::default(), + right_click_paste: Default::default(), alternate_screen: AltScreenMode::Auto, status_line: None, status_line_use_colors: true, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index df6b1258d5..85e2383621 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -792,6 +792,9 @@ pub struct Config { /// Override the terminal-specific default for copying transcript mouse selections. pub tui_copy_on_select: codex_config::types::CopyOnSelect, + /// Right-click text paste fallback for the fullscreen TUI. + pub tui_right_click_paste: codex_config::types::RightClickPaste, + /// Start the TUI in the specified collaboration mode (plan/default). /// Controls whether the TUI uses the terminal's alternate screen buffer. @@ -4498,6 +4501,11 @@ impl Config { .as_ref() .map(|tui| tui.copy_on_select) .unwrap_or_default(), + tui_right_click_paste: cfg + .tui + .as_ref() + .map(|tui| tui.right_click_paste) + .unwrap_or_default(), tui_alternate_screen: cfg .tui .as_ref() diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 605323693d..b8c7410cf6 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -288,6 +288,7 @@ async fn new_config( model_availability_nux: ModelAvailabilityNuxConfig::default(), tui_fullscreen_transcript: false, tui_copy_on_select: Default::default(), + tui_right_click_paste: Default::default(), tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_status_line_use_colors: true, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a1deb1479b..4e8794d090 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -251,6 +251,7 @@ mod reconnect; mod replay_filter; mod resize_reflow; mod resume_config; +mod right_click_paste; mod safety_buffering; mod server_version_notice; mod session_lifecycle; @@ -621,6 +622,8 @@ pub(crate) struct App { environment_manager: Arc, app_server_target: AppServerTarget, reconnect: reconnect::ReconnectState, + pending_right_click_paste: Option, + right_click_paste_environment: right_click_paste::PasteEnvironment, /// Set when the user confirms an update; propagated on exit. daemon_cli_executable: Option, pub(crate) pending_update_action: Option, @@ -867,7 +870,9 @@ impl App { app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { + self.invalidate_right_click_paste(&event); self.finish_clipboard(tui); + let event = self.finish_right_click_paste(tui, event); if matches!(&event, TuiEvent::Key(_)) && self.handle_composer_copy_event(tui, &event, |tui, text| { tui.copy_transcript_selection(text, crate::clipboard_copy::CopyFormat::PlainText) @@ -1158,7 +1163,8 @@ impl App { self.app_event_tx.send(AppEvent::LaunchExternalEditor); } } - TuiEvent::FocusLost | TuiEvent::Mouse(_) => {} + TuiEvent::Mouse(mouse) => self.start_right_click_paste(tui, mouse), + TuiEvent::FocusLost => {} } } Ok(AppRunControl::Continue) diff --git a/codex-rs/tui/src/app/clipboard.rs b/codex-rs/tui/src/app/clipboard.rs index f43b220f4f..9c735c609e 100644 --- a/codex-rs/tui/src/app/clipboard.rs +++ b/codex-rs/tui/src/app/clipboard.rs @@ -22,7 +22,7 @@ impl App { // Finish paste before the next key, but never wait on the copy worker's lock. if tui.clipboard.is_busy() { self.chat_widget.add_info_message( - "Copy already in progress; try image paste after it finishes".into(), + "Clipboard busy; try image paste after it finishes".into(), /*hint*/ None, ); } else { diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index d860430304..48c247aed9 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -1364,6 +1364,16 @@ async fn fullscreen_composer_mouse_copy_and_input_ownership() -> Result<()> { assert!(app.handle_owned_transcript_event(&mut tui, &mut server, &event)?); } assert!(!app.transcript_view.has_active_interaction()); + app.start_right_click_paste( + &mut tui, + crossterm::event::MouseEvent { + kind: Down(Right), + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }, + ); + assert!(!tui.clipboard.is_busy()); app.render_owned_transcript(&mut tui, size)?; let cursor = tui.terminal.last_known_cursor_pos; let draft = app.chat_widget.capture_thread_input_state(); diff --git a/codex-rs/tui/src/app/right_click_paste.rs b/codex-rs/tui/src/app/right_click_paste.rs new file mode 100644 index 0000000000..47b9289d71 --- /dev/null +++ b/codex-rs/tui/src/app/right_click_paste.rs @@ -0,0 +1,138 @@ +//! Right-click fallback for an unchanged, editable fullscreen composer. +//! Existing selection handlers run first. Only a draw may deliver a read, so real input +//! invalidates pending paste before completion and is never replaced by clipboard text. + +use super::*; +use crate::tui::VscodeDetection; +use codex_config::types::RightClickPaste; +use crossterm::event::MouseButton; +use crossterm::event::MouseEvent; +use crossterm::event::MouseEventKind; + +pub(super) struct PendingPaste { + thread: Option, + draft: (String, usize), +} + +pub(super) struct PasteEnvironment { + pub(super) platform_default: bool, + pub(super) ssh: bool, + pub(super) wsl: bool, + pub(super) vscode: VscodeDetection, +} + +impl PasteEnvironment { + pub(super) fn detect() -> Self { + Self { + platform_default: cfg!(any(target_os = "windows", target_os = "linux")), + ssh: crate::clipboard_copy::is_ssh_session(), + wsl: crate::clipboard_copy::is_wsl_session(), + vscode: tui::detect_vscode_terminal(), + } + } + + fn allows(&self, mode: RightClickPaste) -> bool { + if self.ssh || self.vscode == VscodeDetection::VsCode { + return false; + } + match mode { + RightClickPaste::Off => false, + RightClickPaste::On => !cfg!(target_os = "android"), + RightClickPaste::Auto => { + self.platform_default && !(self.wsl && self.vscode == VscodeDetection::Unknown) + } + } + } +} + +impl App { + fn right_click_paste_target(&self, tui: &tui::Tui) -> Option { + if !tui.is_owned_screen() + || self.overlay.is_some() + || self.transcript_view.has_selection_range() + || self.transcript_view.is_search_active() + || !self + .right_click_paste_environment + .allows(self.local_settings.tui.right_click_paste) + { + return None; + } + Some(PendingPaste { + thread: self.current_displayed_thread_id(), + draft: self.chat_widget.right_click_paste_target()?, + }) + } + + pub(super) fn start_right_click_paste(&mut self, tui: &mut tui::Tui, mouse: MouseEvent) { + if mouse.kind != MouseEventKind::Down(MouseButton::Right) + || !mouse.modifiers.is_empty() + || self.pending_right_click_paste.is_some() + || tui.clipboard.is_busy() + { + return; + } + let Some(target) = self.right_click_paste_target(tui) else { + return; + }; + match tui.clipboard.read_text(tui.frame_requester()) { + Ok(true) => self.pending_right_click_paste = Some(target), + Ok(false) => {} + Err(error) => self.chat_widget.add_error_message(error), + } + } + + /// Invalidate before polling, even when the worker has already completed. + pub(super) fn invalidate_right_click_paste(&mut self, event: &TuiEvent) { + let keep = match event { + TuiEvent::Draw | TuiEvent::Resize(_) | TuiEvent::FocusGained => true, + TuiEvent::Mouse(mouse) => { + matches!( + mouse.kind, + MouseEventKind::Moved | MouseEventKind::Up(MouseButton::Right) + ) || (mouse.kind == MouseEventKind::Down(MouseButton::Right) + && mouse.modifiers.is_empty()) + } + TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::FocusLost | TuiEvent::Resume => false, + }; + if !keep { + self.pending_right_click_paste = None; + } + } + + pub(super) fn finish_right_click_paste( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> TuiEvent { + if !matches!(event, TuiEvent::Draw) { + return event; + } + let Some(result) = tui.clipboard.take_text_result() else { + return event; + }; + let Some(pending) = self.pending_right_click_paste.take() else { + return event; + }; + let Some(current) = self.right_click_paste_target(tui) else { + return event; + }; + if pending.thread != current.thread || pending.draft != current.draft { + return event; + } + match result { + Ok(text) if !text.is_empty() => { + tui.frame_requester().schedule_frame(); + TuiEvent::Paste(text) + } + Ok(_) => event, + Err(error) => { + self.chat_widget.add_error_message(error); + event + } + } + } +} + +#[cfg(test)] +#[path = "right_click_paste_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/app/right_click_paste_tests.rs b/codex-rs/tui/src/app/right_click_paste_tests.rs new file mode 100644 index 0000000000..565f7096f6 --- /dev/null +++ b/codex-rs/tui/src/app/right_click_paste_tests.rs @@ -0,0 +1,33 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn policy_preserves_explicit_modes_and_terminal_ownership() { + use VscodeDetection::Other; + use VscodeDetection::Unknown; + use VscodeDetection::VsCode; + for (platform_default, ssh, wsl, vscode, expected) in [ + (true, false, false, Other, [false, true, true]), + (false, false, false, Other, [false, true, false]), + (true, true, false, Other, [false, false, false]), + (true, false, false, VsCode, [false, false, false]), + (true, false, true, Unknown, [false, true, false]), + (true, false, true, Other, [false, true, true]), + ] { + let env = PasteEnvironment { + platform_default, + ssh, + wsl, + vscode, + }; + assert_eq!( + [ + RightClickPaste::Off, + RightClickPaste::On, + RightClickPaste::Auto + ] + .map(|mode| env.allows(mode)), + expected + ); + } +} diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index fad3e284f1..88098615b2 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -491,6 +491,7 @@ impl App { /// replacement widget so that replayed collab items render agent names immediately. pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { self.chat_widget.clear_prompt_suggestion(); + self.pending_right_click_paste = None; if !self.chat_widget.realtime_conversation_is_running() { self.retain_realtime_replay_state_before_replace(); } diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 89bd252a62..cb2c77f5db 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -787,6 +787,8 @@ See the Codex keymap documentation for supported actions and examples." feedback_audience, environment_manager, app_server_target, + pending_right_click_paste: None, + right_click_paste_environment: super::right_click_paste::PasteEnvironment::detect(), reconnect: ReconnectState { seen_version_notice: initial_server_version_notice .as_ref() diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index f1bd6589c4..93c33b7257 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -64,6 +64,13 @@ pub(crate) async fn make_test_app() -> App { feedback_audience: FeedbackAudience::External, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, + pending_right_click_paste: None, + right_click_paste_environment: super::right_click_paste::PasteEnvironment { + platform_default: true, + ssh: false, + wsl: false, + vscode: crate::tui::VscodeDetection::Other, + }, reconnect: Default::default(), daemon_cli_executable: None, pending_update_action: None, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 268ca41008..31a785431b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -6038,6 +6038,13 @@ async fn make_test_app() -> Box { environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, reconnect: Default::default(), + pending_right_click_paste: None, + right_click_paste_environment: super::right_click_paste::PasteEnvironment { + platform_default: true, + ssh: false, + wsl: false, + vscode: crate::tui::VscodeDetection::Other, + }, daemon_cli_executable: None, pending_update_action: None, pending_shutdown_exit_thread_id: None, @@ -6146,6 +6153,13 @@ pub(super) async fn make_test_app_with_channels() -> ( environment_manager: Arc::new(EnvironmentManager::default_for_tests()), app_server_target: crate::AppServerTarget::Embedded, reconnect: Default::default(), + pending_right_click_paste: None, + right_click_paste_environment: super::right_click_paste::PasteEnvironment { + platform_default: true, + ssh: false, + wsl: false, + vscode: crate::tui::VscodeDetection::Other, + }, daemon_cli_executable: None, pending_update_action: None, pending_shutdown_exit_thread_id: None, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 875a8227ed..ff4e78b37a 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -5,6 +5,10 @@ //! bursts, especially on Windows. Paste timing uses Tokio's clock so asynchronous flush deadlines //! and input classification share a clock, including in paused-time tests. Copy shortcuts and right //! clicks preserve selected draft text. +//! When enabled, fullscreen right-click paste requires an editable composer without a selection, +//! search, or blocking view. The app reads clipboard text asynchronously and delivers a normal +//! paste only while the same thread, draft, and cursor remain eligible. Intervening input or focus +//! loss cancels the pending paste; a late clipboard result cannot overwrite newer input. //! The live voice strip renders after effort ignition, followed by the Astra sparkle when eligible. //! Owned transcripts keep persistent status below the composer and hints on a separate final row. //! Shortcut help expands above the composer, with its close hint replacing the final shortcuts row diff --git a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs index 7a7906e410..beb2aad831 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer/mouse.rs @@ -11,6 +11,14 @@ use crossterm::event::MouseEvent; use crossterm::event::MouseEventKind; impl ChatComposer { + pub(crate) fn can_paste_on_right_click(&self) -> bool { + self.draft.input_enabled + && !self.blocks_direct_input + && self.history_search.is_none() + && self.draft.textarea.vim_query().is_none() + && self.draft.textarea.mouse_selection_range().is_none() + } + pub(in crate::bottom_pane) fn finish_copy( &mut self, completion: &(u64, crate::clipboard_copy::worker::CopyResult), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2bdf8f6a00..ce3742916f 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1113,9 +1113,8 @@ impl BottomPane { self.composer.current_text() } - #[cfg(test)] pub(crate) fn composer_cursor(&self) -> usize { - self.composer.cursor() + self.composer.current_cursor() } #[cfg(test)] @@ -1789,6 +1788,10 @@ impl BottomPane { self.composer.copy_selection(event, copy) } + pub(crate) fn can_paste_on_right_click(&self) -> bool { + self.no_modal_or_popup_active() && self.composer.can_paste_on_right_click() + } + pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { if self.has_active_view() || self.questions.as_ref().is_some_and(|q| q.expanded) { self.composer.end_mouse_drag(); diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 1703210b06..41ff9782cf 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -31,6 +31,16 @@ impl ChatWidget { self.bottom_pane.handle_composer_mouse(event) } + /// Snapshot only the editable paste target, without changing pending submissions. + pub(crate) fn right_click_paste_target(&self) -> Option<(String, usize)> { + self.bottom_pane.can_paste_on_right_click().then(|| { + ( + self.bottom_pane.composer_text(), + self.bottom_pane.composer_cursor(), + ) + }) + } + pub(crate) fn prepare_composer_mouse(&mut self, event: crossterm::event::MouseEvent) -> bool { self.bottom_pane.prepare_composer_mouse(event) } diff --git a/codex-rs/tui/src/clipboard_copy.rs b/codex-rs/tui/src/clipboard_copy.rs index 7cfd529f34..96123c5f57 100644 --- a/codex-rs/tui/src/clipboard_copy.rs +++ b/codex-rs/tui/src/clipboard_copy.rs @@ -222,7 +222,7 @@ fn copy_to_clipboard_with( } /// Detect whether the current process is running inside an SSH session. -fn is_ssh_session() -> bool { +pub(crate) fn is_ssh_session() -> bool { std::env::var_os("SSH_TTY").is_some() || std::env::var_os("SSH_CONNECTION").is_some() } @@ -232,12 +232,12 @@ fn is_tmux_session() -> bool { } #[cfg(target_os = "linux")] -fn is_wsl_session() -> bool { +pub(crate) fn is_wsl_session() -> bool { crate::clipboard_paste::is_probably_wsl() } #[cfg(not(target_os = "linux"))] -fn is_wsl_session() -> bool { +pub(crate) fn is_wsl_session() -> bool { false } diff --git a/codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__image_paste_while_copy_is_busy.snap b/codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__image_paste_while_copy_is_busy.snap index 338861399e..1ca0443f01 100644 --- a/codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__image_paste_while_copy_is_busy.snap +++ b/codex-rs/tui/src/clipboard_copy/snapshots/codex_tui__clipboard_copy__worker__tests__image_paste_while_copy_is_busy.snap @@ -1,6 +1,6 @@ --- source: tui/src/clipboard_copy/worker_tests.rs -assertion_line: 82 +assertion_line: 112 expression: "feedback.join(\"\\n\")" --- -• Copy already in progress; try image paste after it finishes +• Clipboard busy; try image paste after it finishes diff --git a/codex-rs/tui/src/clipboard_copy/worker.rs b/codex-rs/tui/src/clipboard_copy/worker.rs index f6362a9d11..db7af42ae3 100644 --- a/codex-rs/tui/src/clipboard_copy/worker.rs +++ b/codex-rs/tui/src/clipboard_copy/worker.rs @@ -1,7 +1,8 @@ //! One session-lived worker owns blocking clipboard operations and native leases. //! Setup has a five-second budget, but abandoning it cannot interrupt an OS call. //! There is no backlog: the worker stays busy until that call returns. Once delivery -//! starts it must finish. Terminal escape sequences are emitted by the UI. +//! starts it must finish. Text reads have the same budget for the whole operation; late +//! results are discarded. Terminal escape sequences are emitted by the UI. use super::ClipboardLease; use super::CopyFormat; @@ -68,10 +69,23 @@ impl CopySetup { pub(crate) type CopyResult = Result; -struct Request { - text: Arc, - format: CopyFormat, - setup: Arc, +enum Request { + Copy { + text: Arc, + format: CopyFormat, + setup: Arc, + }, + Read { + deadline: Instant, + response: mpsc::Sender>, + }, +} + +struct PendingRead { + frames: FrameRequester, + deadline: Instant, + response: mpsc::Receiver>, + expired: bool, } struct Response { @@ -86,11 +100,13 @@ pub(crate) struct ClipboardWorker { pending: Option<(u64, Arc)>, next_id: u64, completed: Option<(u64, CopyResult)>, + pending_read: Option, + read_result: Option<(Instant, Result)>, } impl ClipboardWorker { pub(crate) fn is_busy(&self) -> bool { - self.pending.is_some() + self.pending.is_some() || self.pending_read.is_some() } pub(crate) fn copy( @@ -105,23 +121,7 @@ impl ClipboardWorker { if text.is_empty() { return Err("nothing to copy: the selected content is empty".into()); } - if self.requests.is_none() { - self.start(frames.clone(), |text, format, setup| { - let terminal_text = Cell::new(/*value*/ None); - let result = super::copy_to_clipboard( - text, - format, - || setup.begin_delivery(), - |text| { - // Validate the limit before accepting a deferred terminal send. - super::osc52_sequence(text, std::env::var_os("TMUX").is_some())?; - terminal_text.set(Some(text.to_owned())); - Ok(()) - }, - ); - (result, terminal_text.into_inner()) - })?; - } + self.ensure_started(frames.clone())?; self.next_id += 1; let id = self.next_id; let setup = Arc::new(CopySetup { @@ -131,7 +131,7 @@ impl ClipboardWorker { self.requests .as_ref() .ok_or("clipboard worker stopped")? - .send(Request { + .send(Request::Copy { text, format, setup: Arc::clone(&setup), @@ -144,6 +144,65 @@ impl ClipboardWorker { Ok(CopyStatus::Pending(id)) } + fn ensure_started(&mut self, frames: FrameRequester) -> Result<(), String> { + if self.requests.is_none() { + self.start( + frames, + |text, format, setup| { + let terminal_text = Cell::new(/*value*/ None); + let result = super::copy_to_clipboard( + text, + format, + || setup.begin_delivery(), + |text| { + // Validate the limit before accepting a deferred terminal send. + super::osc52_sequence(text, std::env::var_os("TMUX").is_some())?; + terminal_text.set(Some(text.to_owned())); + Ok(()) + }, + ); + (result, terminal_text.into_inner()) + }, + crate::clipboard_paste::text::read, + )?; + } + Ok(()) + } + + /// Start one text read. A timed-out native call keeps the shared worker occupied. + pub(crate) fn read_text(&mut self, frames: FrameRequester) -> Result { + if self.is_busy() { + return Ok(false); + } + self.ensure_started(frames.clone())?; + let deadline = Instant::now() + SETUP_TIMEOUT; + let (response, receiver) = mpsc::channel(); + self.requests + .as_ref() + .ok_or("clipboard worker stopped")? + .send(Request::Read { deadline, response }) + .map_err(|_| "clipboard worker stopped".to_string())?; + self.pending_read = Some(PendingRead { + frames: frames.clone(), + deadline, + response: receiver, + expired: false, + }); + self.read_result = None; + frames.schedule_frame_in(SETUP_TIMEOUT); + Ok(true) + } + + pub(crate) fn take_text_result(&mut self) -> Option> { + self.read_result.take().map(|(deadline, result)| { + if Instant::now() >= deadline { + Err("clipboard read timed out".into()) + } else { + result + } + }) + } + fn start( &mut self, frames: FrameRequester, @@ -154,6 +213,7 @@ impl ClipboardWorker { ) -> (Result, Option) + Send + 'static, + mut read: impl FnMut(Instant) -> Result + Send + 'static, ) -> Result<(), String> { let (requests, incoming) = mpsc::channel::(); let (outgoing, responses) = mpsc::channel(); @@ -163,8 +223,18 @@ impl ClipboardWorker { .spawn(move || { let mut lease: Option = None; while let Ok(request) = incoming.recv() { - let (outcome, terminal_text) = - copy(&request.text, request.format, &request.setup); + let (outcome, terminal_text) = match request { + Request::Copy { + text, + format, + setup, + } => copy(&text, format, &setup), + Request::Read { deadline, response } => { + let _ = response.send(read(deadline)); + frames.schedule_frame(); + continue; + } + }; let result = outcome.map(|outcome| outcome.store(&mut lease)); if outgoing .send(Response { @@ -190,6 +260,34 @@ impl ClipboardWorker { /// Poll only while the UI owns the terminal. Retain one result for its original consumer. pub(crate) fn poll(&mut self) -> Option<&(u64, CopyResult)> { + if let Some(read) = &mut self.pending_read { + if !read.expired && Instant::now() >= read.deadline { + read.expired = true; + self.read_result = Some((read.deadline, Err("clipboard read timed out".into()))); + } + match read.response.try_recv() { + Ok(result) => { + if !read.expired { + self.read_result = Some((read.deadline, result)); + } + self.pending_read = None; + } + Err(mpsc::TryRecvError::Disconnected) => { + if !read.expired { + self.read_result = + Some((read.deadline, Err("clipboard worker stopped".into()))); + } + self.pending_read = None; + } + Err(mpsc::TryRecvError::Empty) => { + if !read.expired { + read.frames.schedule_frame_in( + read.deadline.saturating_duration_since(Instant::now()), + ); + } + } + } + } if let Some((id, setup)) = &self.pending { let id = *id; if !matches!(self.completed, Some((completed_id, _)) if completed_id == id) diff --git a/codex-rs/tui/src/clipboard_copy/worker_tests.rs b/codex-rs/tui/src/clipboard_copy/worker_tests.rs index a75d4c08af..298a767c95 100644 --- a/codex-rs/tui/src/clipboard_copy/worker_tests.rs +++ b/codex-rs/tui/src/clipboard_copy/worker_tests.rs @@ -20,12 +20,16 @@ async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion() let (started, start_rx) = mpsc::channel(); let (release, released) = mpsc::channel(); tui.clipboard - .start(frames.clone(), move |text, format, setup| { - setup.begin_delivery().unwrap(); - started.send((text.to_owned(), format)).unwrap(); - released.recv().unwrap(); - (Ok(CopyOutcome::Copied(Some(ClipboardLease::test()))), None) - }) + .start( + frames.clone(), + move |text, format, setup| { + setup.begin_delivery().unwrap(); + started.send((text.to_owned(), format)).unwrap(); + released.recv().unwrap(); + (Ok(CopyOutcome::Copied(Some(ClipboardLease::test()))), None) + }, + |_| unreachable!("copy-only backend"), + ) .unwrap(); let mut app = Box::new(crate::app::test_support::make_test_app().await); let (mut chat, tx, mut events, _ops) = @@ -69,12 +73,10 @@ async fn blocked_copy_allows_overlay_exit_rejects_backlog_and_wakes_completion() .unwrap(), ("café\nsecond line".into(), CopyFormat::Markdown) ); - for _ in 0..100 { - assert_eq!( - tui.copy_transcript_selection("newer", CopyFormat::PlainText), - Ok(CopyStatus::Busy) - ); - } + assert_eq!( + tui.copy_transcript_selection("newer", CopyFormat::PlainText), + Ok(CopyStatus::Busy) + ); assert!(tui.clipboard.poll().is_none()); let mut overlay = Overlay::new_transcript( vec![Arc::new(PlainHistoryCell::new(vec!["selected".into()]))], @@ -126,12 +128,16 @@ fn dropping_worker_does_not_wait_for_blocked_backend() { let (release, released) = mpsc::channel(); let (finished, finish_rx) = mpsc::channel(); worker - .start(FrameRequester::test_dummy(), move |_, _, _| { - started.send(()).unwrap(); - released.recv().unwrap(); - finished.send(()).unwrap(); - (Err("unavailable".into()), None) - }) + .start( + FrameRequester::test_dummy(), + move |_, _, _| { + started.send(()).unwrap(); + released.recv().unwrap(); + finished.send(()).unwrap(); + (Err("unavailable".into()), None) + }, + |_| unreachable!("copy-only backend"), + ) .unwrap(); worker .copy( @@ -160,15 +166,19 @@ async fn expired_setup_discards_delivery_and_keeps_worker_busy_until_it_returns( let (release, released) = mpsc::channel(); let (writes, write_rx) = mpsc::channel(); worker - .start(frames.clone(), move |text, _, setup| { - started.send(()).unwrap(); - released.recv().unwrap(); - let outcome = setup.begin_delivery().map(|()| { - writes.send(text.to_owned()).unwrap(); - CopyOutcome::Copied(None) - }); - (outcome, None) - }) + .start( + frames.clone(), + move |text, _, setup| { + started.send(()).unwrap(); + released.recv().unwrap(); + let outcome = setup.begin_delivery().map(|()| { + writes.send(text.to_owned()).unwrap(); + CopyOutcome::Copied(None) + }); + (outcome, None) + }, + |_| unreachable!("copy-only backend"), + ) .unwrap(); worker .copy("abandoned".into(), CopyFormat::PlainText, frames.clone()) @@ -224,13 +234,9 @@ async fn expired_setup_discards_delivery_and_keeps_worker_busy_until_it_returns( fn drop_waits_for_response_channel_disconnect() { let (requests, _incoming) = mpsc::channel(); let (outgoing, responses) = mpsc::sync_channel(/*bound*/ 0); - let mut worker = ClipboardWorker { - requests: Some(requests), - responses: Some(responses), - pending: None, - next_id: 0, - completed: None, - }; + let mut worker = ClipboardWorker::default(); + worker.requests = Some(requests); + worker.responses = Some(responses); worker .copy( "text".into(), @@ -271,3 +277,133 @@ fn delivery_claim_cannot_be_abandoned() { // Backend fallback checks cannot revoke a delivery that already began. assert_eq!(setup.begin_delivery(), Ok(())); } + +#[tokio::test] +async fn right_click_paste_uses_normal_input_and_discards_stale_reads() { + use codex_config::types::RightClickPaste; + use crossterm::event::MouseButton; + use crossterm::event::MouseEvent; + use crossterm::event::MouseEventKind; + let mut tui = crate::tui::test_support::make_test_tui().unwrap(); + let (started, start_rx) = mpsc::channel(); + let (release, released) = mpsc::channel(); + tui.clipboard + .start( + tui.frame_requester(), + |_, _, _| unreachable!("no selection"), + move |_| { + started.send(()).unwrap(); + released.recv().unwrap(); + Ok("café\r\nsecond line\n".into()) + }, + ) + .unwrap(); + let mut app = Box::new(crate::app::test_support::make_test_app().await); + app.local_settings.tui.right_click_paste = RightClickPaste::On; + let mut server = crate::start_embedded_app_server_for_picker(&app.config) + .await + .unwrap(); + let click = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 1, + row: 1, + modifiers: KeyModifiers::NONE, + }; + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click)) + .await + .unwrap(); + assert!(!tui.clipboard.is_busy()); + tui.set_owned_screen(/*owned*/ true).unwrap(); + for (change, expected) in [ + ("none", "draft café\nsecond line\n"), + ("native", "draft native"), + ("edit", "replacement"), + ("disable", "draft "), + ] { + app.local_settings.tui.right_click_paste = RightClickPaste::On; + app.chat_widget.apply_external_edit("draft ".into()); + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click)) + .await + .unwrap(); + start_rx.recv_timeout(SETUP_TIMEOUT).unwrap(); + if change == "none" { + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Mouse(click)) + .await + .unwrap(); + assert!(start_rx.try_recv().is_err()); + assert_eq!( + tui.clipboard + .copy("copy".into(), CopyFormat::PlainText, tui.frame_requester()), + Ok(CopyStatus::Busy) + ); + app.handle_tui_event( + &mut tui, + &mut server, + TuiEvent::Mouse(MouseEvent { + kind: MouseEventKind::Up(MouseButton::Right), + ..click + }), + ) + .await + .unwrap(); + } else if change == "native" { + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Paste("native".into())) + .await + .unwrap(); + } else if change == "edit" { + app.chat_widget.apply_external_edit("replacement".into()); + } else if change == "disable" { + app.local_settings.tui.right_click_paste = RightClickPaste::Off; + } + release.send(()).unwrap(); + tokio::time::timeout(SETUP_TIMEOUT, async { + while tui.clipboard.is_busy() { + app.handle_tui_event(&mut tui, &mut server, TuiEvent::Draw) + .await + .unwrap(); + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert_eq!(app.chat_widget.composer_text_with_pending(), expected); + } +} + +#[test] +fn expired_text_read_rejects_backlog_and_discards_late_completion() { + let mut worker = ClipboardWorker::default(); + let (release, released) = mpsc::channel(); + worker + .start( + FrameRequester::test_dummy(), + |_, _, _| unreachable!(), + move |_| { + released.recv().unwrap(); + Ok("late".into()) + }, + ) + .unwrap(); + assert!(worker.read_text(FrameRequester::test_dummy()).unwrap()); + worker.pending_read.as_mut().unwrap().deadline = Instant::now(); + worker.poll(); + assert_eq!( + worker.take_text_result(), + Some(Err("clipboard read timed out".into())) + ); + assert!(!worker.read_text(FrameRequester::test_dummy()).unwrap()); + release.send(()).unwrap(); + let deadline = Instant::now() + SETUP_TIMEOUT; + while worker.is_busy() && Instant::now() < deadline { + worker.poll(); + std::thread::yield_now(); + } + assert!(!worker.is_busy()); + assert_eq!(worker.take_text_result(), None); + // A completion harvested by a non-draw event must retain its acceptance deadline. + worker.read_result = Some((Instant::now(), Ok("completed but no longer current".into()))); + assert_eq!( + worker.take_text_result(), + Some(Err("clipboard read timed out".into())) + ); +} diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 55ca6ca659..cc5489d6e0 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -1,3 +1,7 @@ +//! Read clipboard images and text on the TUI host. Text reads run on the clipboard worker. + +pub(crate) mod text; + use std::path::Path; use std::path::PathBuf; use tempfile::Builder; diff --git a/codex-rs/tui/src/clipboard_paste/text.rs b/codex-rs/tui/src/clipboard_paste/text.rs new file mode 100644 index 0000000000..5c98bf86d6 --- /dev/null +++ b/codex-rs/tui/src/clipboard_paste/text.rs @@ -0,0 +1,96 @@ +//! Local clipboard text reads, executed only by the shared clipboard worker. +//! WSL reads the Windows clipboard; it never falls back to a separate Linux clipboard. + +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use std::time::Instant; + +pub(crate) fn read(deadline: Instant) -> Result { + if crate::clipboard_copy::is_ssh_session() { + return Err("clipboard text is unavailable over SSH".into()); + } + #[cfg(target_os = "linux")] + if super::is_probably_wsl() { + let executable = codex_utils_path::system_executable("powershell.exe") + .ok_or("Windows clipboard reader is unavailable")?; + let mut command = tokio::process::Command::new(executable); + command.args(["-NoProfile", "-NonInteractive", "-Command", + "$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); [Console]::Out.Write((Get-Clipboard -Raw))"]); + return tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| "could not start clipboard reader")? + .block_on(read_command(command, deadline)); + } + #[cfg(not(target_os = "android"))] + let text = match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.get_text()) { + Ok(text) => text, + Err(arboard::Error::ContentNotAvailable) => String::new(), + Err(_) => return Err("clipboard text is unavailable".into()), + }; + #[cfg(target_os = "android")] + let text = String::new(); + validate(text, deadline) +} + +fn validate(text: String, deadline: Instant) -> Result { + if Instant::now() >= deadline { + Err("clipboard read timed out".into()) + } else if text.chars().count() > MAX_USER_INPUT_TEXT_CHARS { + Err("clipboard text exceeds the message size limit".into()) + } else { + Ok(text) + } +} + +#[cfg(any(target_os = "linux", all(test, unix)))] +async fn read_command( + mut command: tokio::process::Command, + deadline: Instant, +) -> Result { + use std::process::Stdio; + use tokio::io::AsyncReadExt; + + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(/*kill_on_drop*/ true) + .spawn() + .map_err(|_| "could not start Windows clipboard reader")?; + // Spawn can itself stall under WSL. Both the UI and this read retain the original deadline. + let result = tokio::time::timeout_at(deadline.into(), async { + let stdout = child + .stdout + .take() + .ok_or("clipboard reader has no output")?; + let limit = MAX_USER_INPUT_TEXT_CHARS * 4; + let mut bytes = Vec::new(); + stdout + .take((limit + 1) as u64) + .read_to_end(&mut bytes) + .await + .map_err(|_| "could not read clipboard text")?; + if bytes.len() > limit { + return Err("clipboard text exceeds the message size limit"); + } + if !child + .wait() + .await + .map_err(|_| "clipboard reader failed")? + .success() + { + return Err("clipboard text is unavailable"); + } + String::from_utf8(bytes).map_err(|_| "clipboard text is not UTF-8") + }) + .await + .unwrap_or(Err("clipboard read timed out")); + if result.is_err() { + let _ = child.kill().await; + } + validate(result.map_err(str::to_owned)?, deadline) +} + +#[cfg(test)] +#[path = "text_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/clipboard_paste/text_tests.rs b/codex-rs/tui/src/clipboard_paste/text_tests.rs new file mode 100644 index 0000000000..387cc39472 --- /dev/null +++ b/codex-rs/tui/src/clipboard_paste/text_tests.rs @@ -0,0 +1,38 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::time::Duration; + +#[test] +fn text_preserves_whitespace_and_rejects_late_or_oversized_reads() { + let deadline = Instant::now() + Duration::from_secs(/*secs*/ 5); + assert_eq!( + validate(" café\r\n\t".into(), deadline), + Ok(" café\r\n\t".into()) + ); + assert!(validate("late".into(), Instant::now()).is_err()); + assert!(validate("x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1), deadline).is_err()); +} + +#[cfg(unix)] +#[tokio::test] +async fn command_drains_large_output_and_bounds_waiting() { + let mut command = tokio::process::Command::new("sh"); + command.args([ + "-c", + "head -c 131072 /dev/zero | tr '\\000' x; printf '\\n'", + ]); + assert_eq!( + read_command(command, Instant::now() + Duration::from_secs(/*secs*/ 5)).await, + Ok(format!("{}\n", "x".repeat(/*n*/ 131072))) + ); + let mut command = tokio::process::Command::new("sh"); + command.args(["-c", "exec sleep 10"]); + assert_eq!( + read_command( + command, + Instant::now() + Duration::from_millis(/*millis*/ 20) + ) + .await, + Err("clipboard read timed out".into()) + ); +} diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs index a8cde6c697..d8ac496aaa 100644 --- a/codex-rs/tui/src/local_settings.rs +++ b/codex-rs/tui/src/local_settings.rs @@ -78,6 +78,7 @@ impl LocalSettings { raw_output_mode: config.tui_raw_output_mode, fullscreen_transcript: config.tui_fullscreen_transcript, copy_on_select: config.tui_copy_on_select, + right_click_paste: config.tui_right_click_paste, alternate_screen: config.tui_alternate_screen, status_line: config.tui_status_line.clone(), status_line_use_colors: config.tui_status_line_use_colors, diff --git a/codex-rs/tui/src/local_settings_tests.rs b/codex-rs/tui/src/local_settings_tests.rs index 3c8cc2b346..a5d50e3b96 100644 --- a/codex-rs/tui/src/local_settings_tests.rs +++ b/codex-rs/tui/src/local_settings_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::edit::ConfigEditsBuilder; use codex_config::LoaderOverrides; +use codex_config::types::RightClickPaste; use codex_config::types::SessionPickerViewMode; use codex_terminal_detection::Multiplexer; use pretty_assertions::assert_eq; @@ -19,6 +20,7 @@ async fn launch_screen_mode_survives_configuration_reload() -> anyhow::Result<() .await?; config.tui_fullscreen_transcript = true; config.tui_copy_on_select = CopyOnSelect::Always; + config.tui_right_click_paste = RightClickPaste::On; config.tui_alternate_screen = AltScreenMode::Auto; for (alternate_screen, owned, expected_mode, expected_alt) in [ @@ -38,11 +40,13 @@ async fn launch_screen_mode_survives_configuration_reload() -> anyhow::Result<() let mut reloaded_config = config.clone(); reloaded_config.tui_fullscreen_transcript = false; reloaded_config.tui_copy_on_select = CopyOnSelect::Never; + reloaded_config.tui_right_click_paste = RightClickPaste::Off; reloaded_config.tui_alternate_screen = AltScreenMode::Never; reloaded_config.tui_theme = Some("nord".into()); let mut expected = LocalSettings::from(&reloaded_config); expected.transcript_mode = expected_mode; expected.tui.alternate_screen = expected_alt; + expected.tui.right_click_paste = RightClickPaste::Off; assert_eq!(local.reloaded(&reloaded_config), expected); assert_eq!(LocalSettings::for_tui(&reloaded_config, &tui), expected); tui.set_owned_screen(/*owned*/ false)?; @@ -102,6 +106,7 @@ show_server_version_notice = false auto_recap = false fullscreen_transcript = true copy_on_select = "never" +right_click_paste = "off" vim_mode_default = true terminal_resize_reflow_max_rows = 0 session_picker_view = "comfortable" @@ -130,6 +135,7 @@ fast_default_opt_out = true }) .cli_overrides(vec![ ("tui.disable_paste_burst".into(), true.into()), + ("tui.right_click_paste".into(), "on".into()), // The deprecated flag must not override or migrate into the TUI preference. ( "features.transcript_v2".into(), @@ -142,6 +148,7 @@ fast_default_opt_out = true let local = LocalSettings::from(&config); let mut expected: Tui = toml::from_str("")?; expected.disable_paste_burst = Some(true); + expected.right_click_paste = RightClickPaste::On; expected.session_picker_view = Some(SessionPickerViewMode::Dense); if !config_text.is_empty() { expected.animations = false; diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index fc5a1dc483..ecd00e058a 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -103,6 +103,9 @@ pub(crate) struct InitializedTerminal { pub(crate) stderr_guard: terminal_stderr::TerminalStderrGuard, } +pub(crate) use keyboard_modes::VscodeDetection; +pub(crate) use keyboard_modes::detect_vscode_terminal; + pub(crate) fn running_in_vscode_terminal() -> bool { keyboard_modes::running_in_vscode_terminal() } diff --git a/codex-rs/tui/src/tui/keyboard_modes.rs b/codex-rs/tui/src/tui/keyboard_modes.rs index bf467c8371..335be9fa4d 100644 --- a/codex-rs/tui/src/tui/keyboard_modes.rs +++ b/codex-rs/tui/src/tui/keyboard_modes.rs @@ -20,7 +20,7 @@ use ratatui::crossterm::execute; const DISABLE_KEYBOARD_ENHANCEMENT_ENV_VAR: &str = "CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT"; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum VscodeDetection { +pub(crate) enum VscodeDetection { VsCode, Other, Unknown, @@ -84,7 +84,7 @@ pub(super) fn running_in_vscode_terminal() -> bool { detect_vscode_terminal() == VscodeDetection::VsCode } -fn detect_vscode_terminal() -> VscodeDetection { +pub(crate) fn detect_vscode_terminal() -> VscodeDetection { if term_program_is_vscode(std::env::var("TERM_PROGRAM").ok().as_deref()) { return VscodeDetection::VsCode; }