diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4529b6656..4eeeb4bce 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -257,6 +257,8 @@ impl ChatComposer { return false; }; + // normalize_pasted_path already handles Windows → WSL path conversion, + // so we can directly try to read the image dimensions. match image::image_dimensions(&path_buf) { Ok((w, h)) => { tracing::info!("OK: {pasted}"); diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 11a97c783..d47ffec98 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -1,3 +1,5 @@ +#[cfg(target_os = "linux")] +use crate::clipboard_paste::is_probably_wsl; use crate::key_hint; use crate::key_hint::KeyBinding; use crate::render::line_utils::prefix_lines; @@ -94,10 +96,19 @@ fn footer_lines(props: FooterProps) -> Vec> { ]); vec![line] } - FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState { - use_shift_enter_hint: props.use_shift_enter_hint, - esc_backtrack_hint: props.esc_backtrack_hint, - }), + FooterMode::ShortcutOverlay => { + #[cfg(target_os = "linux")] + let is_wsl = is_probably_wsl(); + #[cfg(not(target_os = "linux"))] + let is_wsl = false; + + let state = ShortcutsState { + use_shift_enter_hint: props.use_shift_enter_hint, + esc_backtrack_hint: props.esc_backtrack_hint, + is_wsl, + }; + shortcut_overlay_lines(state) + } FooterMode::EscHint => vec![esc_hint_line(props.esc_backtrack_hint)], FooterMode::ContextOnly => vec![context_window_line( props.context_window_percent, @@ -115,6 +126,7 @@ struct CtrlCReminderState { struct ShortcutsState { use_shift_enter_hint: bool, esc_backtrack_hint: bool, + is_wsl: bool, } fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> { @@ -271,6 +283,7 @@ enum DisplayCondition { Always, WhenShiftEnterHint, WhenNotShiftEnterHint, + WhenUnderWSL, } impl DisplayCondition { @@ -279,6 +292,7 @@ impl DisplayCondition { DisplayCondition::Always => true, DisplayCondition::WhenShiftEnterHint => state.use_shift_enter_hint, DisplayCondition::WhenNotShiftEnterHint => !state.use_shift_enter_hint, + DisplayCondition::WhenUnderWSL => state.is_wsl, } } } @@ -352,10 +366,18 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ }, ShortcutDescriptor { id: ShortcutId::PasteImage, - bindings: &[ShortcutBinding { - key: key_hint::ctrl(KeyCode::Char('v')), - condition: DisplayCondition::Always, - }], + // Show Ctrl+Alt+V when running under WSL (terminals often intercept plain + // Ctrl+V); otherwise fall back to Ctrl+V. + bindings: &[ + ShortcutBinding { + key: key_hint::ctrl_alt(KeyCode::Char('v')), + condition: DisplayCondition::WhenUnderWSL, + }, + ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('v')), + condition: DisplayCondition::Always, + }, + ], prefix: "", label: " to paste images", }, diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 2a669f5c4..5863c728b 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -2,7 +2,7 @@ use std::path::Path; use std::path::PathBuf; use tempfile::Builder; -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum PasteImageError { ClipboardUnavailable(String), NoImage(String), @@ -119,19 +119,113 @@ pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageErro /// Convenience: write to a temp file and return its path + info. #[cfg(not(target_os = "android"))] pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { - let (png, info) = paste_image_as_png()?; - // Create a unique temporary file with a .png suffix to avoid collisions. - let tmp = Builder::new() - .prefix("codex-clipboard-") - .suffix(".png") - .tempfile() - .map_err(|e| PasteImageError::IoError(e.to_string()))?; - std::fs::write(tmp.path(), &png).map_err(|e| PasteImageError::IoError(e.to_string()))?; - // Persist the file (so it remains after the handle is dropped) and return its PathBuf. - let (_file, path) = tmp - .keep() - .map_err(|e| PasteImageError::IoError(e.error.to_string()))?; - Ok((path, info)) + // First attempt: read image from system clipboard via arboard (native paths or image data). + match paste_image_as_png() { + Ok((png, info)) => { + // Create a unique temporary file with a .png suffix to avoid collisions. + let tmp = Builder::new() + .prefix("codex-clipboard-") + .suffix(".png") + .tempfile() + .map_err(|e| PasteImageError::IoError(e.to_string()))?; + std::fs::write(tmp.path(), &png) + .map_err(|e| PasteImageError::IoError(e.to_string()))?; + // Persist the file (so it remains after the handle is dropped) and return its PathBuf. + let (_file, path) = tmp + .keep() + .map_err(|e| PasteImageError::IoError(e.error.to_string()))?; + Ok((path, info)) + } + Err(e) => { + #[cfg(target_os = "linux")] + { + try_wsl_clipboard_fallback(&e).or(Err(e)) + } + #[cfg(not(target_os = "linux"))] + { + Err(e) + } + } + } +} + +/// Attempt WSL fallback for clipboard image paste. +/// +/// If clipboard is unavailable (common under WSL because arboard cannot access +/// the Windows clipboard), attempt a WSL fallback that calls PowerShell on the +/// Windows side to write the clipboard image to a temporary file, then return +/// the corresponding WSL path. +#[cfg(target_os = "linux")] +fn try_wsl_clipboard_fallback( + error: &PasteImageError, +) -> Result<(PathBuf, PastedImageInfo), PasteImageError> { + use PasteImageError::ClipboardUnavailable; + use PasteImageError::NoImage; + + if !is_probably_wsl() || !matches!(error, ClipboardUnavailable(_) | NoImage(_)) { + return Err(error.clone()); + } + + tracing::debug!("attempting Windows PowerShell clipboard fallback"); + let Some(win_path) = try_dump_windows_clipboard_image() else { + return Err(error.clone()); + }; + + tracing::debug!("powershell produced path: {}", win_path); + let Some(mapped_path) = convert_windows_path_to_wsl(&win_path) else { + return Err(error.clone()); + }; + + let Ok((w, h)) = image::image_dimensions(&mapped_path) else { + return Err(error.clone()); + }; + + // Return the mapped path directly without copying. + // The file will be read and base64-encoded during serialization. + Ok(( + mapped_path, + PastedImageInfo { + width: w, + height: h, + encoded_format: EncodedImageFormat::Png, + }, + )) +} + +/// Try to call a Windows PowerShell command (several common names) to save the +/// clipboard image to a temporary PNG and return the Windows path to that file. +/// Returns None if no command succeeded or no image was present. +#[cfg(target_os = "linux")] +fn try_dump_windows_clipboard_image() -> Option { + // Powershell script: save image from clipboard to a temp png and print the path. + // Force UTF-8 output to avoid encoding issues between powershell.exe (UTF-16LE default) + // and pwsh (UTF-8 default). + let script = r#"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; $img = Get-Clipboard -Format Image; if ($img -ne $null) { $p=[System.IO.Path]::GetTempFileName(); $p = [System.IO.Path]::ChangeExtension($p,'png'); $img.Save($p,[System.Drawing.Imaging.ImageFormat]::Png); Write-Output $p } else { exit 1 }"#; + + for cmd in ["powershell.exe", "pwsh", "powershell"] { + match std::process::Command::new(cmd) + .args(["-NoProfile", "-Command", script]) + .output() + { + // Executing PowerShell command + Ok(output) => { + if output.status.success() { + // Decode as UTF-8 (forced by the script above). + let win_path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !win_path.is_empty() { + tracing::debug!("{} saved clipboard image to {}", cmd, win_path); + return Some(win_path); + } + } else { + tracing::debug!("{} returned non-zero status", cmd); + } + } + Err(err) => { + tracing::debug!("{} not executable: {}", cmd, err); + } + } + } + None } #[cfg(target_os = "android")] @@ -202,10 +296,19 @@ pub fn normalize_pasted_path(pasted: &str) -> Option { } #[cfg(target_os = "linux")] -fn is_probably_wsl() -> bool { - std::env::var_os("WSL_DISTRO_NAME").is_some() - || std::env::var_os("WSL_INTEROP").is_some() - || std::env::var_os("WSLENV").is_some() +pub(crate) fn is_probably_wsl() -> bool { + // Primary: Check /proc/version for "microsoft" or "WSL" (most reliable for standard WSL). + if let Ok(version) = std::fs::read_to_string("/proc/version") { + let version_lower = version.to_lowercase(); + if version_lower.contains("microsoft") || version_lower.contains("wsl") { + return true; + } + } + + // Fallback: Check WSL environment variables. This handles edge cases like + // custom Linux kernels installed in WSL where /proc/version may not contain + // "microsoft" or "WSL". + std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some() } #[cfg(target_os = "linux")] @@ -253,40 +356,6 @@ pub fn pasted_image_format(path: &Path) -> EncodedImageFormat { #[cfg(test)] mod pasted_paths_tests { use super::*; - #[cfg(target_os = "linux")] - use std::ffi::OsString; - - #[cfg(target_os = "linux")] - struct EnvVarGuard { - key: &'static str, - original: Option, - } - - #[cfg(target_os = "linux")] - impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - let original = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, original } - } - } - - #[cfg(target_os = "linux")] - impl Drop for EnvVarGuard { - fn drop(&mut self) { - if let Some(original) = &self.original { - unsafe { - std::env::set_var(self.key, original); - } - } else { - unsafe { - std::env::remove_var(self.key); - } - } - } - } #[cfg(not(windows))] #[test] @@ -420,7 +489,11 @@ mod pasted_paths_tests { #[cfg(target_os = "linux")] #[test] fn normalize_windows_path_in_wsl() { - let _guard = EnvVarGuard::set("WSL_DISTRO_NAME", "Ubuntu-24.04"); + // This test only runs on actual WSL systems + if !is_probably_wsl() { + // Skip test if not on WSL + return; + } let input = r"C:\\Users\\Alice\\Pictures\\example image.png"; let result = normalize_pasted_path(input).expect("should convert windows path on wsl"); assert_eq!( diff --git a/codex-rs/tui/src/key_hint.rs b/codex-rs/tui/src/key_hint.rs index 6272ab0df..515419ee0 100644 --- a/codex-rs/tui/src/key_hint.rs +++ b/codex-rs/tui/src/key_hint.rs @@ -49,6 +49,10 @@ pub(crate) const fn ctrl(key: KeyCode) -> KeyBinding { KeyBinding::new(key, KeyModifiers::CONTROL) } +pub(crate) const fn ctrl_alt(key: KeyCode) -> KeyBinding { + KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT)) +} + fn modifiers_to_string(modifiers: KeyModifiers) -> String { let mut result = String::new(); if modifiers.contains(KeyModifiers::CONTROL) {