diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 24eb0e4bab5..32dd1f12300 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1575,6 +1575,7 @@ dependencies = [ "supports-color 3.0.2", "tempfile", "textwrap 0.16.2", + "thiserror 2.0.17", "tokio", "tokio-stream", "tokio-util", @@ -1589,6 +1590,7 @@ dependencies = [ "url", "uuid", "vt100", + "windows-sys 0.52.0", ] [[package]] diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 4e5fad06b4b..9e73803756f 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -72,6 +72,7 @@ strum_macros = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } textwrap = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", @@ -98,6 +99,12 @@ tokio-util = { workspace = true, features = ["time"] } [target.'cfg(unix)'.dependencies] libc = { workspace = true } +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_System_Console", +] } + # Clipboard support via `arboard` is not available on Android/Termux. # Only include it for non-Android targets so the crate builds on Android. [target.'cfg(not(target_os = "android"))'.dependencies] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5397a2eaecf..b86bdcda4b2 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3,9 +3,13 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::chatwidget::ChatWidget; +use crate::chatwidget::ExternalEditorState; use crate::diff_render::DiffSummary; +use crate::editor; +use crate::editor::EditorError; use crate::exec_command::strip_bash_lc_and_escape; use crate::file_search::FileSearchManager; +use crate::history_cell; use crate::history_cell::HistoryCell; use crate::model_migration::ModelMigrationOutcome; use crate::model_migration::migration_copy_for_models; @@ -58,10 +62,13 @@ use std::thread; use std::time::Duration; use tokio::select; use tokio::sync::mpsc::unbounded_channel; +use tokio::time; #[cfg(not(debug_assertions))] use crate::history_cell::UpdateAvailableHistoryCell; +const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; + #[derive(Debug, Clone)] pub struct AppExitInfo { pub token_usage: TokenUsage, @@ -486,6 +493,10 @@ impl App { tui: &mut tui::Tui, event: TuiEvent, ) -> Result { + // If the external editor is active, don't process any tui events. + if self.chat_widget.external_editor_state() == ExternalEditorState::Active { + return Ok(true); + } if self.overlay.is_some() { let _ = self.handle_backtrack_overlay_event(tui, event).await?; } else { @@ -518,6 +529,12 @@ impl App { } }, )?; + if self.chat_widget.external_editor_state() == ExternalEditorState::Requested { + self.chat_widget + .set_external_editor_state(ExternalEditorState::Active); + self.app_event_tx + .send(AppEvent::LaunchExternalEditorAfterDraw); + } } } } @@ -780,6 +797,17 @@ impl App { AppEvent::OpenFeedbackConsent { category } => { self.chat_widget.open_feedback_consent(category); } + AppEvent::ExternalEditorRequestTimeout => { + // Reset the external editor state if it was requested and no draw occurred in time. + if self.chat_widget.external_editor_state() == ExternalEditorState::Requested { + self.reset_external_editor_state(tui); + } + } + AppEvent::LaunchExternalEditorAfterDraw => { + if self.chat_widget.external_editor_state() == ExternalEditorState::Active { + self.launch_external_editor(tui).await; + } + } AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { self.chat_widget.open_windows_sandbox_enable_prompt(preset); } @@ -1086,6 +1114,130 @@ impl App { self.config.model_reasoning_effort = effort; } + async fn launch_external_editor(&mut self, tui: &mut tui::Tui) { + let editor_cmd = match editor::resolve_editor_command() { + Ok(cmd) => cmd, + Err(EditorError::MissingEditor) => { + self.chat_widget + .add_to_history(history_cell::new_error_event( + "Cannot open external editor: set $VISUAL or $EDITOR".to_string(), + )); + self.reset_external_editor_state(tui); + return; + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + self.reset_external_editor_state(tui); + return; + } + }; + + // Leave alt screen if active to avoid conflicts with editor. + // This is defensive as we gate the external editor launch on there being no overlay. + let was_alt_screen = tui.is_alt_screen_active(); + if was_alt_screen { + let _ = tui.leave_alt_screen(); + } + + let restore_modes = tui::restore_keep_raw(); + if let Err(err) = restore_modes { + tracing::warn!("failed to restore terminal modes before editor: {err}"); + } + + let seed = self.chat_widget.composer_text_with_pending(); + let editor_result = editor::run_editor(&seed, &editor_cmd).await; + + if let Err(err) = tui::set_modes() { + tracing::warn!("failed to re-enable terminal modes after editor: {err}"); + } + // After the editor exits, reset terminal state and flush any buffered keypresses. + Self::flush_terminal_input_buffer(); + self.reset_external_editor_state(tui); + + if was_alt_screen { + let _ = tui.enter_alt_screen(); + } + + match editor_result { + Ok(new_text) => { + // Trim trailing whitespace + let cleaned = new_text.trim_end().to_string(); + self.chat_widget.apply_external_edit(cleaned); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + } + } + tui.frame_requester().schedule_frame(); + } + + #[cfg(unix)] + fn flush_terminal_input_buffer() { + // Safety: flushing the stdin queue is safe and does not move ownership. + let result = unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) }; + if result != 0 { + let err = std::io::Error::last_os_error(); + tracing::warn!("failed to tcflush stdin: {err}"); + } + } + + #[cfg(windows)] + fn flush_terminal_input_buffer() { + use windows_sys::Win32::Foundation::GetLastError; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::FlushConsoleInputBuffer; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; + + let handle = unsafe { GetStdHandle(STD_INPUT_HANDLE) }; + if handle == INVALID_HANDLE_VALUE || handle == 0 { + let err = unsafe { GetLastError() }; + tracing::warn!("failed to get stdin handle for flush: error {err}"); + return; + } + + let result = unsafe { FlushConsoleInputBuffer(handle) }; + if result == 0 { + let err = unsafe { GetLastError() }; + tracing::warn!("failed to flush stdin buffer: error {err}"); + } + } + + #[cfg(not(any(unix, windows)))] + fn flush_terminal_input_buffer() {} + + fn request_external_editor_launch(&mut self, tui: &mut tui::Tui) { + self.chat_widget + .set_external_editor_state(ExternalEditorState::Requested); + tui.pause_events(); + self.chat_widget.set_footer_hint_override(Some(vec![( + EXTERNAL_EDITOR_HINT.to_string(), + String::new(), + )])); + tui.frame_requester().schedule_frame(); + + // Right now this sends the timeout event regardless of whether the frame was drawn. + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + time::sleep(Duration::from_millis(500)).await; + tx.send(AppEvent::ExternalEditorRequestTimeout); + }); + } + + fn reset_external_editor_state(&mut self, tui: &mut tui::Tui) { + self.chat_widget + .set_external_editor_state(ExternalEditorState::Closed); + self.chat_widget.set_footer_hint_override(None); + tui.resume_events(); + tui.frame_requester().schedule_frame(); + } + async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { match key_event { KeyEvent { @@ -1099,6 +1251,21 @@ impl App { self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); tui.frame_requester().schedule_frame(); } + KeyEvent { + code: KeyCode::Char('g'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + // Only launch the external editor if there is no overlay and the bottom pane is not in use. + // Note that it can be launched while a task is running to enable editing while the previous turn is ongoing. + if self.overlay.is_none() + && self.chat_widget.can_launch_external_editor() + && self.chat_widget.external_editor_state() == ExternalEditorState::Closed + { + self.request_external_editor_launch(tui); + } + } // Esc primes/advances backtracking only in normal (not working) mode // with the composer focused and empty. In any other state, forward // Esc so the active UI (e.g. status indicator, modals, popups) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index adb9c1308e8..6748667df42 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -175,6 +175,12 @@ pub(crate) enum AppEvent { OpenFeedbackConsent { category: FeedbackCategory, }, + + /// Clear a pending external editor request if no draw occurred in time. + ExternalEditorRequestTimeout, + + /// Launch the external editor after a normal draw has completed. + LaunchExternalEditorAfterDraw, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 919866b00c9..105897fabd9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -13,7 +13,9 @@ use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; use ratatui::widgets::Block; +use ratatui::widgets::Paragraph; use ratatui::widgets::StatefulWidgetRef; +use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; @@ -24,7 +26,7 @@ use super::footer::FooterMode; use super::footer::FooterProps; use super::footer::esc_hint_mode; use super::footer::footer_height; -use super::footer::render_footer; +use super::footer::prefixed_footer_lines; use super::footer::reset_mode_after_activity; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; @@ -282,6 +284,85 @@ impl ChatComposer { } } + /// Replace the composer content with text from an external editor. + /// Clears pending paste placeholders and keeps only attachments whose + /// placeholder labels still appear in the new text. Cursor is placed at + /// the end after rebuilding elements. + pub(crate) fn apply_external_edit(&mut self, text: String) { + self.pending_pastes.clear(); + + // Count placeholder occurrences in the new text. + let mut placeholder_counts: HashMap = HashMap::new(); + for placeholder in self.attached_images.iter().map(|img| &img.placeholder) { + if placeholder_counts.contains_key(placeholder) { + continue; + } + let count = text.match_indices(placeholder).count(); + if count > 0 { + placeholder_counts.insert(placeholder.clone(), count); + } + } + + // Keep attachments only while we have matching occurrences left. + let mut kept_images = Vec::new(); + for img in self.attached_images.drain(..) { + if let Some(count) = placeholder_counts.get_mut(&img.placeholder) + && *count > 0 + { + *count -= 1; + kept_images.push(img); + } + } + self.attached_images = kept_images; + + // Rebuild textarea so placeholders become elements again. + self.textarea.set_text(""); + let mut remaining: HashMap<&str, usize> = HashMap::new(); + for img in &self.attached_images { + *remaining.entry(img.placeholder.as_str()).or_insert(0) += 1; + } + + let mut occurrences: Vec<(usize, &str)> = Vec::new(); + for placeholder in remaining.keys() { + for (pos, _) in text.match_indices(placeholder) { + occurrences.push((pos, *placeholder)); + } + } + occurrences.sort_unstable_by_key(|(pos, _)| *pos); + + let mut idx = 0usize; + for (pos, ph) in occurrences { + let Some(count) = remaining.get_mut(ph) else { + continue; + }; + if *count == 0 { + continue; + } + if pos > idx { + self.textarea.insert_str(&text[idx..pos]); + } + self.textarea.insert_element(ph); + *count -= 1; + idx = pos + ph.len(); + } + if idx < text.len() { + self.textarea.insert_str(&text[idx..]); + } + + self.textarea.set_cursor(self.textarea.text().len()); + self.sync_popups(); + } + + pub(crate) fn current_text_with_pending(&self) -> String { + let mut text = self.textarea.text().to_string(); + for (placeholder, actual) in &self.pending_pastes { + if text.contains(placeholder) { + text = text.replace(placeholder, actual); + } + } + text + } + /// Override the footer hint items displayed beneath the composer. Passing /// `None` restores the default shortcut footer. pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { @@ -321,7 +402,8 @@ impl ChatComposer { .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| "image".to_string()); - let placeholder = format!("[{file_label} {width}x{height}]"); + let base_placeholder = format!("{file_label} {width}x{height}"); + let placeholder = self.next_image_placeholder(&base_placeholder); // Insert as an element to match large paste placeholder behavior: // styled distinctly and treated atomically for cursor/mutations. self.textarea.insert_element(&placeholder); @@ -384,6 +466,22 @@ impl ChatComposer { } } + fn next_image_placeholder(&mut self, base: &str) -> String { + let text = self.textarea.text(); + let mut suffix = 1; + loop { + let placeholder = if suffix == 1 { + format!("[{base}]") + } else { + format!("[{base} #{suffix}]") + }; + if !text.contains(&placeholder) { + return placeholder; + } + suffix += 1; + } + } + pub(crate) fn insert_str(&mut self, text: &str) { self.textarea.insert_str(text); self.sync_popups(); @@ -1778,6 +1876,47 @@ impl ChatComposer { self.footer_mode = reset_mode_after_activity(self.footer_mode); } } + + fn footer_hint_rect(popup_rect: Rect, footer_hint_height: u16, footer_spacing: u16) -> Rect { + if footer_spacing > 0 && footer_hint_height > 0 { + let [_, hint_rect] = Layout::vertical([ + Constraint::Length(footer_spacing), + Constraint::Length(footer_hint_height), + ]) + .areas(popup_rect); + hint_rect + } else { + popup_rect + } + } + + fn footer_render_data( + &self, + hint_rect: Rect, + footer_props: FooterProps, + ) -> Option<(Rect, Vec>)> { + if let Some(items) = self.footer_hint_override.as_ref() { + if items.is_empty() { + return None; + } + let mut spans = Vec::with_capacity(items.len() * 4); + for (idx, (key, label)) in items.iter().enumerate() { + spans.push(" ".into()); + spans.push(Span::styled(key.clone(), Style::default().bold())); + spans.push(format!(" {label}").into()); + if idx + 1 != items.len() { + spans.push(" ".into()); + } + } + let mut custom_rect = hint_rect; + if custom_rect.width > 2 { + custom_rect.x += 2; + custom_rect.width = custom_rect.width.saturating_sub(2); + } + return Some((custom_rect, vec![Line::from(spans)])); + } + Some((hint_rect, prefixed_footer_lines(footer_props))) + } } impl Renderable for ChatComposer { @@ -1824,36 +1963,10 @@ impl Renderable for ChatComposer { let footer_hint_height = custom_height.unwrap_or_else(|| footer_height(footer_props)); let footer_spacing = Self::footer_spacing(footer_hint_height); - let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 { - let [_, hint_rect] = Layout::vertical([ - Constraint::Length(footer_spacing), - Constraint::Length(footer_hint_height), - ]) - .areas(popup_rect); - hint_rect - } else { - popup_rect - }; - if let Some(items) = self.footer_hint_override.as_ref() { - if !items.is_empty() { - let mut spans = Vec::with_capacity(items.len() * 4); - for (idx, (key, label)) in items.iter().enumerate() { - spans.push(" ".into()); - spans.push(Span::styled(key.clone(), Style::default().bold())); - spans.push(format!(" {label}").into()); - if idx + 1 != items.len() { - spans.push(" ".into()); - } - } - let mut custom_rect = hint_rect; - if custom_rect.width > 2 { - custom_rect.x += 2; - custom_rect.width = custom_rect.width.saturating_sub(2); - } - Line::from(spans).render_ref(custom_rect, buf); - } - } else { - render_footer(hint_rect, buf, footer_props); + let hint_rect = + Self::footer_hint_rect(popup_rect, footer_hint_height, footer_spacing); + if let Some((rect, lines)) = self.footer_render_data(hint_rect, footer_props) { + Paragraph::new(lines).render(rect, buf); } } } @@ -3150,6 +3263,35 @@ mod tests { assert!(composer.attached_images.is_empty()); } + #[test] + fn duplicate_image_placeholders_get_suffix() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + let path = PathBuf::from("/tmp/image_dup.png"); + composer.attach_image(path.clone(), 10, 5, "PNG"); + composer.handle_paste(" ".into()); + composer.attach_image(path, 10, 5, "PNG"); + + let text = composer.textarea.text().to_string(); + assert!(text.contains("[image_dup.png 10x5]")); + assert!(text.contains("[image_dup.png 10x5 #2]")); + assert_eq!( + composer.attached_images[0].placeholder, + "[image_dup.png 10x5]" + ); + assert_eq!( + composer.attached_images[1].placeholder, + "[image_dup.png 10x5 #2]" + ); + } + #[test] fn image_placeholder_backspace_behaves_like_text_placeholder() { let (tx, _rx) = unbounded_channel::(); @@ -3999,4 +4141,116 @@ mod tests { "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" ); } + + #[test] + fn apply_external_edit_rebuilds_text_and_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let placeholder = "[image 10x10]".to_string(); + composer.textarea.insert_element(&placeholder); + composer.attached_images.push(AttachedImage { + placeholder: placeholder.clone(), + path: PathBuf::from("img.png"), + }); + composer + .pending_pastes + .push(("[Pasted]".to_string(), "data".to_string())); + + composer.apply_external_edit(format!("Edited {placeholder} text")); + + assert_eq!( + composer.current_text(), + format!("Edited {placeholder} text") + ); + assert!(composer.pending_pastes.is_empty()); + assert_eq!(composer.attached_images.len(), 1); + assert_eq!(composer.attached_images[0].placeholder, placeholder); + assert_eq!(composer.textarea.cursor(), composer.current_text().len()); + } + + #[test] + fn apply_external_edit_drops_missing_attachments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let placeholder = "[image 10x10]".to_string(); + composer.textarea.insert_element(&placeholder); + composer.attached_images.push(AttachedImage { + placeholder: placeholder.clone(), + path: PathBuf::from("img.png"), + }); + + composer.apply_external_edit("No images here".to_string()); + + assert_eq!(composer.current_text(), "No images here".to_string()); + assert!(composer.attached_images.is_empty()); + } + + #[test] + fn current_text_with_pending_expands_placeholders() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let placeholder = "[Pasted Content 5 chars]".to_string(); + composer.textarea.insert_element(&placeholder); + composer + .pending_pastes + .push((placeholder.clone(), "hello".to_string())); + + assert_eq!( + composer.current_text_with_pending(), + "hello".to_string(), + "placeholder should expand to actual text" + ); + } + + #[test] + fn apply_external_edit_limits_duplicates_to_occurrences() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + let placeholder = "[image 10x10]".to_string(); + composer.textarea.insert_element(&placeholder); + composer.attached_images.push(AttachedImage { + placeholder: placeholder.clone(), + path: PathBuf::from("img.png"), + }); + + composer.apply_external_edit(format!("{placeholder} extra {placeholder}")); + + assert_eq!( + composer.current_text(), + format!("{placeholder} extra {placeholder}") + ); + assert_eq!(composer.attached_images.len(), 1); + } } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index d47ffec98b6..1a639767a6b 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -6,13 +6,9 @@ use crate::render::line_utils::prefix_lines; use crate::status::format_tokens_compact; use crate::ui_consts::FOOTER_INDENT_COLS; use crossterm::event::KeyCode; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::text::Span; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; #[derive(Clone, Copy, Debug)] pub(crate) struct FooterProps { @@ -66,13 +62,12 @@ pub(crate) fn footer_height(props: FooterProps) -> u16 { footer_lines(props).len() as u16 } -pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) { - Paragraph::new(prefix_lines( +pub(crate) fn prefixed_footer_lines(props: FooterProps) -> Vec> { + prefix_lines( footer_lines(props), " ".repeat(FOOTER_INDENT_COLS).into(), " ".repeat(FOOTER_INDENT_COLS).into(), - )) - .render(area, buf); + ) } fn footer_lines(props: FooterProps) -> Vec> { @@ -162,6 +157,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { let mut newline = Line::from(""); let mut file_paths = Line::from(""); let mut paste_image = Line::from(""); + let mut external_editor = Line::from(""); let mut edit_previous = Line::from(""); let mut quit = Line::from(""); let mut show_transcript = Line::from(""); @@ -173,6 +169,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { ShortcutId::InsertNewline => newline = text, ShortcutId::FilePaths => file_paths = text, ShortcutId::PasteImage => paste_image = text, + ShortcutId::ExternalEditor => external_editor = text, ShortcutId::EditPrevious => edit_previous = text, ShortcutId::Quit => quit = text, ShortcutId::ShowTranscript => show_transcript = text, @@ -185,6 +182,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { newline, file_paths, paste_image, + external_editor, edit_previous, quit, Line::from(""), @@ -261,6 +259,7 @@ enum ShortcutId { InsertNewline, FilePaths, PasteImage, + ExternalEditor, EditPrevious, Quit, ShowTranscript, @@ -381,6 +380,15 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[ prefix: "", label: " to paste images", }, + ShortcutDescriptor { + id: ShortcutId::ExternalEditor, + bindings: &[ShortcutBinding { + key: key_hint::ctrl(KeyCode::Char('g')), + condition: DisplayCondition::Always, + }], + prefix: "", + label: " to edit in external editor", + }, ShortcutDescriptor { id: ShortcutId::EditPrevious, bindings: &[ShortcutBinding { @@ -416,6 +424,9 @@ mod tests { use insta::assert_snapshot; use ratatui::Terminal; use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use ratatui::widgets::Paragraph; + use ratatui::widgets::Widget; fn snapshot_footer(name: &str, props: FooterProps) { let height = footer_height(props).max(1); @@ -423,7 +434,7 @@ mod tests { terminal .draw(|f| { let area = Rect::new(0, 0, f.area().width, height); - render_footer(area, f.buffer_mut(), props); + Paragraph::new(prefixed_footer_lines(props)).render(area, f.buffer_mut()); }) .unwrap(); assert_snapshot!(name, terminal.backend()); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 85166872840..1eaeb04b1db 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -266,6 +266,20 @@ impl BottomPane { self.composer.current_text() } + pub(crate) fn composer_text_with_pending(&self) -> String { + self.composer.current_text_with_pending() + } + + pub(crate) fn apply_external_edit(&mut self, text: String) { + self.composer.apply_external_edit(text); + self.request_redraw(); + } + + pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + self.composer.set_footer_hint_override(items); + self.request_redraw(); + } + /// Update the animated header shown to the left of the brackets in the /// status indicator (defaults to "Working"). No-ops if the status /// indicator is not active. @@ -414,6 +428,11 @@ impl BottomPane { !self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active() } + /// Return true when no popups or modal views are active, regardless of task state. + pub(crate) fn can_launch_external_editor(&self) -> bool { + self.view_stack.is_empty() && !self.composer.popup_active() + } + pub(crate) fn show_view(&mut self, view: Box) { self.push_view(view); } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap index 3b6782d06d6..7d05a922370 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_shortcut_overlay.snap @@ -10,7 +10,8 @@ expression: terminal.backend() " " " " " " -" / for commands shift + enter for newline " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap index 264515a6c2b..445fa44484c 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_shortcuts_shift_and_esc.snap @@ -2,7 +2,8 @@ source: tui/src/bottom_pane/footer.rs expression: terminal.backend() --- -" / for commands shift + enter for newline " -" @ for file paths ctrl + v to paste images " -" esc again to edit previous message ctrl + c to exit " -" ctrl + t to view transcript " +" / for commands shift + enter for newline " +" @ for file paths ctrl + v to paste images " +" ctrl + g to edit in external editor esc again to edit previous message " +" ctrl + c to exit " +" ctrl + t to view transcript " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e3f95a612e8..43fcb7b05fc 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -276,6 +276,14 @@ enum RateLimitSwitchPromptState { Shown, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum ExternalEditorState { + #[default] + Closed, + Requested, + Active, +} + pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, @@ -333,6 +341,7 @@ pub(crate) struct ChatWidget { feedback: codex_feedback::CodexFeedback, // Current session rollout path (if known) current_rollout_path: Option, + external_editor_state: ExternalEditorState, } struct UserMessage { @@ -1330,6 +1339,7 @@ impl ChatWidget { last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, + external_editor_state: ExternalEditorState::Closed, }; widget.prefetch_rate_limits(); @@ -1415,6 +1425,7 @@ impl ChatWidget { last_rendered_width: std::cell::Cell::new(None), feedback, current_rollout_path: None, + external_editor_state: ExternalEditorState::Closed, }; widget.prefetch_rate_limits(); @@ -1513,6 +1524,31 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn composer_text_with_pending(&self) -> String { + self.bottom_pane.composer_text_with_pending() + } + + pub(crate) fn apply_external_edit(&mut self, text: String) { + self.bottom_pane.apply_external_edit(text); + self.request_redraw(); + } + + pub(crate) fn external_editor_state(&self) -> ExternalEditorState { + self.external_editor_state + } + + pub(crate) fn set_external_editor_state(&mut self, state: ExternalEditorState) { + self.external_editor_state = state; + } + + pub(crate) fn set_footer_hint_override(&mut self, items: Option>) { + self.bottom_pane.set_footer_hint_override(items); + } + + pub(crate) fn can_launch_external_editor(&self) -> bool { + self.bottom_pane.can_launch_external_editor() + } + fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!( @@ -1686,7 +1722,7 @@ impl ChatWidget { } } - fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { + pub(crate) fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { self.add_boxed_history(Box::new(cell)); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 65f6708c915..20408716101 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -442,6 +442,7 @@ fn make_chatwidget_manual( last_rendered_width: std::cell::Cell::new(None), feedback: codex_feedback::CodexFeedback::new(), current_rollout_path: None, + external_editor_state: ExternalEditorState::Closed, }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/editor.rs b/codex-rs/tui/src/editor.rs new file mode 100644 index 00000000000..79a25ad3e88 --- /dev/null +++ b/codex-rs/tui/src/editor.rs @@ -0,0 +1,142 @@ +use std::env; +use std::fs; +use std::process::Stdio; + +use color_eyre::eyre::Report; +use color_eyre::eyre::Result; +use shlex::split as shlex_split; +use tempfile::Builder; +use thiserror::Error; +use tokio::process::Command; + +#[derive(Debug, Error)] +pub(crate) enum EditorError { + #[error("neither VISUAL nor EDITOR is set")] + MissingEditor, + #[error("failed to parse editor command")] + ParseFailed, + #[error("editor command is empty")] + EmptyCommand, +} + +/// Resolve the editor command from environment variables. +/// Prefers `VISUAL` over `EDITOR`. +pub(crate) fn resolve_editor_command() -> std::result::Result, EditorError> { + let raw = env::var("VISUAL") + .or_else(|_| env::var("EDITOR")) + .map_err(|_| EditorError::MissingEditor)?; + let parts = shlex_split(&raw).ok_or(EditorError::ParseFailed)?; + if parts.is_empty() { + return Err(EditorError::EmptyCommand); + } + Ok(parts) +} + +/// Write `seed` to a temp file, launch the editor command, and return the updated content. +pub(crate) async fn run_editor(seed: &str, editor_cmd: &[String]) -> Result { + if editor_cmd.is_empty() { + return Err(Report::msg("editor command is empty")); + } + + let tempfile = Builder::new().suffix(".md").tempfile()?; + fs::write(tempfile.path(), seed)?; + + let mut cmd = Command::new(&editor_cmd[0]); + if editor_cmd.len() > 1 { + cmd.args(&editor_cmd[1..]); + } + let status = cmd + .arg(tempfile.path()) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await?; + + if !status.success() { + return Err(Report::msg(format!("editor exited with status {status}"))); + } + + let contents = fs::read_to_string(tempfile.path())?; + Ok(contents) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serial_test::serial; + #[cfg(unix)] + use tempfile::tempdir; + + struct EnvGuard { + visual: Option, + editor: Option, + } + + impl EnvGuard { + fn new() -> Self { + Self { + visual: env::var("VISUAL").ok(), + editor: env::var("EDITOR").ok(), + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + restore_env("VISUAL", self.visual.take()); + restore_env("EDITOR", self.editor.take()); + } + } + + fn restore_env(key: &str, value: Option) { + match value { + Some(val) => unsafe { env::set_var(key, val) }, + None => unsafe { env::remove_var(key) }, + } + } + + #[test] + #[serial] + fn resolve_editor_prefers_visual() { + let _guard = EnvGuard::new(); + unsafe { + env::set_var("VISUAL", "vis"); + env::set_var("EDITOR", "ed"); + } + let cmd = resolve_editor_command().unwrap(); + assert_eq!(cmd, vec!["vis".to_string()]); + } + + #[test] + #[serial] + fn resolve_editor_errors_when_unset() { + let _guard = EnvGuard::new(); + unsafe { + env::remove_var("VISUAL"); + env::remove_var("EDITOR"); + } + assert!(matches!( + resolve_editor_command(), + Err(EditorError::MissingEditor) + )); + } + + #[tokio::test] + #[cfg(unix)] + async fn run_editor_returns_updated_content() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let script_path = dir.path().join("edit.sh"); + fs::write(&script_path, "#!/bin/sh\nprintf \"edited\" > \"$1\"\n").unwrap(); + let mut perms = fs::metadata(&script_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script_path, perms).unwrap(); + + let cmd = vec![script_path.to_string_lossy().to_string()]; + let result = run_editor("seed", &cmd).await.unwrap(); + assert_eq!(result, "edited".to_string()); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 36883b8664f..b424b4648e0 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -45,6 +45,7 @@ mod clipboard_paste; mod color; pub mod custom_terminal; mod diff_render; +mod editor; mod exec_cell; mod exec_command; mod file_search; diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index f9566da9080..0f6da39b887 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -119,18 +119,31 @@ impl Command for DisableAlternateScroll { } } -/// Restore the terminal to its original state. -/// Inverse of `set_modes`. -pub fn restore() -> Result<()> { +fn restore_common(should_disable_raw_mode: bool) -> Result<()> { // Pop may fail on platforms that didn't support the push; ignore errors. let _ = execute!(stdout(), PopKeyboardEnhancementFlags); execute!(stdout(), DisableBracketedPaste)?; let _ = execute!(stdout(), DisableFocusChange); - disable_raw_mode()?; + if should_disable_raw_mode { + disable_raw_mode()?; + } let _ = execute!(stdout(), crossterm::cursor::Show); Ok(()) } +/// Restore the terminal to its original state. +/// Inverse of `set_modes`. +pub fn restore() -> Result<()> { + let should_disable_raw_mode = true; + restore_common(should_disable_raw_mode) +} + +/// Restore the terminal to its original state, but keep raw mode enabled. +pub fn restore_keep_raw() -> Result<()> { + let should_disable_raw_mode = false; + restore_common(should_disable_raw_mode) +} + /// Initialize the terminal (inline viewport; history stays in normal scrollback) pub fn init() -> Result { if !stdin().is_terminal() { @@ -175,6 +188,8 @@ pub struct Tui { alt_screen_active: Arc, // True when terminal/tab is focused; updated internally from crossterm events terminal_focused: Arc, + // When true, crossterm events are not polled. + events_paused: Arc, enhanced_keys_supported: bool, notification_backend: Option, } @@ -201,6 +216,7 @@ impl Tui { suspend_context: SuspendContext::new(), alt_screen_active: Arc::new(AtomicBool::new(false)), terminal_focused: Arc::new(AtomicBool::new(true)), + events_paused: Arc::new(AtomicBool::new(false)), enhanced_keys_supported, notification_backend: Some(detect_backend()), } @@ -214,6 +230,20 @@ impl Tui { self.enhanced_keys_supported } + /// Pause crossterm event polling. Idempotent. + pub fn pause_events(&self) { + self.events_paused.store(true, Ordering::Relaxed); + } + + /// Resume crossterm event polling. Idempotent. + pub fn resume_events(&self) { + self.events_paused.store(false, Ordering::Relaxed); + } + + pub fn is_alt_screen_active(&self) -> bool { + self.alt_screen_active.load(Ordering::Relaxed) + } + /// Emit a desktop notification now if the terminal is unfocused. /// Returns true if a notification was posted. pub fn notify(&mut self, message: impl AsRef) -> bool { @@ -274,8 +304,24 @@ impl Tui { let alt_screen_active = self.alt_screen_active.clone(); let terminal_focused = self.terminal_focused.clone(); + let events_paused = self.events_paused.clone(); let event_stream = async_stream::stream! { loop { + // If events are paused, we only process draw events. + if events_paused.load(Ordering::Relaxed) { + match draw_rx.recv().await { + Ok(_) => { + yield TuiEvent::Draw; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + yield TuiEvent::Draw; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + break; + } + } + continue; + } select! { event_result = crossterm_events.next() => { match event_result {