diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4429858c91..5d587cb4df 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1611,6 +1611,7 @@ dependencies = [ "supports-color", "tempfile", "textwrap 0.16.2", + "thiserror 2.0.17", "tokio", "tokio-stream", "tokio-util", diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 248205c427..466bc32d69 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", diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6120c7978d..8fb6b896cf 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4,8 +4,11 @@ use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::chatwidget::ChatWidget; 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_config; @@ -43,9 +46,13 @@ use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; +use crossterm::cursor::MoveTo; +use crossterm::event; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::terminal::Clear; +use crossterm::terminal::ClearType; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; @@ -56,12 +63,15 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::thread; use std::time::Duration; +use unicode_width::UnicodeWidthStr; + use tokio::select; use tokio::sync::mpsc::unbounded_channel; #[cfg(not(debug_assertions))] use crate::history_cell::UpdateAvailableHistoryCell; +const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; @@ -1008,6 +1018,180 @@ 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(), + )); + return; + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + return; + } + }; + + // Seed the temp file with the full composer text (including pending pastes), + let seed = self.chat_widget.composer_text_with_pending(); + // show a footer hint, and make sure it's visible before we drop TUI modes. + self.chat_widget.set_footer_hint_override(Some(vec![( + EXTERNAL_EDITOR_HINT.to_string(), + String::new(), + )])); + self.force_redraw_now(tui); + let hint_width = UnicodeWidthStr::width(EXTERNAL_EDITOR_HINT) as u16; + // Park the cursor after/below the hint. + let cursor_row = self.move_cursor_for_external_editor(tui, hint_width); + + // 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(); + if let Err(err) = restore_modes { + tracing::warn!("failed to restore terminal modes before editor: {err}"); + } + + 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, flush any buffered keypresses, + // and clear the area below the bottom pane, as it may have been scribbled over while the external editor was open. + Self::discard_pending_terminal_input(); + self.clear_bottom_pane_rows(tui, cursor_row); + self.chat_widget.set_footer_hint_override(None); + + if was_alt_screen { + let _ = tui.enter_alt_screen(); + } + self.force_redraw_now(tui); + + match editor_result { + Ok(new_text) => { + // Trim trailing whitespace + let cleaned = new_text.trim_end().to_string(); + self.chat_widget.apply_external_edit(cleaned); + tui.frame_requester().schedule_frame(); + } + Err(err) => { + self.chat_widget + .add_to_history(history_cell::new_error_event(format!( + "Failed to open editor: {err}", + ))); + } + } + } + + /// Drain any keystrokes the OS delivered while the external editor was running. + fn discard_pending_terminal_input() { + loop { + match event::poll(Duration::from_millis(0)) { + Ok(true) => { + if let Err(err) = event::read() { + tracing::warn!("failed to read pending terminal input: {err}"); + break; + } + } + Ok(false) => break, + Err(err) => { + tracing::warn!("failed to poll for pending terminal input: {err}"); + break; + } + } + } + } + + /// Clear the portion of the screen occupied by the bottom pane (or the provided row). + fn clear_bottom_pane_rows(&mut self, tui: &mut tui::Tui, from_row: Option) { + let Some((pane_start, _pane_height)) = self.pane_rows(tui) else { + return; + }; + let Ok(area) = tui.terminal.size() else { + return; + }; + let start_row = from_row + .unwrap_or(pane_start) + .min(area.height.saturating_sub(1)); + let viewport_x = tui.terminal.viewport_area.x; + let backend = tui.terminal.backend_mut(); + if let Err(err) = crossterm::execute!( + backend, + MoveTo(viewport_x, start_row), + Clear(ClearType::FromCursorDown) + ) { + tracing::warn!("failed to clear prompt area after editor: {err}"); + } + } + + /// Park the cursor after/below the hint. + /// This prevents keystrokes while the external editor is open from overwriting TUI state. + fn move_cursor_for_external_editor(&self, tui: &mut tui::Tui, hint_width: u16) -> Option { + let (pane_start, pane_height) = self.pane_rows(tui)?; + let area = tui.terminal.size().ok()?; + let pane_bottom = pane_start.saturating_add(pane_height.saturating_sub(1)); + let row = pane_bottom + .saturating_add(1) + .min(area.height.saturating_sub(1)); + let mut col = tui.terminal.viewport_area.x; + if row == pane_bottom { + // When we must reuse the footer row, offset the cursor past the hint so it stays readable. + let max_col = area.width.saturating_sub(1); + col = col.saturating_add(hint_width.saturating_add(1).min(max_col)); + } + if let Err(err) = crossterm::execute!(tui.terminal.backend_mut(), MoveTo(col, row)) { + tracing::warn!("failed to reposition cursor before launching editor: {err}"); + return None; + } + Some(row) + } + + /// Force an immediate render. + fn force_redraw_now(&mut self, tui: &mut tui::Tui) { + let Ok(area) = tui.terminal.size() else { + return; + }; + let desired_height = self.chat_widget.desired_height(area.width); + if let Err(err) = tui.draw(desired_height, |frame| { + self.chat_widget.render(frame.area(), frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + frame.set_cursor_position((x, y)); + } + }) { + tracing::warn!("failed to redraw TUI before launching editor: {err:?}"); + } + } + + /// Return the top row and height of the bottom pane within the current viewport. + fn pane_rows(&self, tui: &tui::Tui) -> Option<(u16, u16)> { + let area = tui.terminal.size().ok()?; + let viewport = tui.terminal.viewport_area; + if viewport.height == 0 || area.height == 0 { + return None; + } + let pane_height = self + .chat_widget + .bottom_pane_height(area.width) + .min(viewport.height); + if pane_height == 0 { + return None; + } + let pane_start = viewport + .y + .saturating_add(viewport.height.saturating_sub(pane_height)); + Some((pane_start, pane_height)) + } + async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { match key_event { KeyEvent { @@ -1021,6 +1205,18 @@ 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.launch_external_editor(tui).await; + } + } // 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/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4529b66566..bd30a5b158 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -104,6 +104,7 @@ pub(crate) struct ChatComposer { current_file_query: Option, pending_pastes: Vec<(String, String)>, large_paste_counters: HashMap, + image_placeholder_counters: HashMap, has_focus: bool, attached_images: Vec, placeholder_text: String, @@ -154,6 +155,7 @@ impl ChatComposer { current_file_query: None, pending_pastes: Vec::new(), large_paste_counters: HashMap::new(), + image_placeholder_counters: HashMap::new(), has_focus: has_input_focus, attached_images: Vec::new(), placeholder_text, @@ -279,6 +281,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>) { @@ -318,7 +399,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); @@ -381,6 +463,19 @@ impl ChatComposer { } } + fn next_image_placeholder(&mut self, base: &str) -> String { + let counter = self + .image_placeholder_counters + .entry(base.to_string()) + .or_insert(0); + *counter += 1; + if *counter == 1 { + format!("[{base}]") + } else { + format!("[{base} #{counter}]") + } + } + pub(crate) fn insert_str(&mut self, text: &str) { self.textarea.insert_str(text); self.sync_popups(); @@ -3105,6 +3200,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::(); @@ -3876,4 +4000,163 @@ mod tests { assert_eq!(composer.textarea.text(), "z".repeat(count)); assert!(composer.pending_pastes.is_empty()); } + + #[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); + } + + #[test] + fn apply_external_edit_drops_extra_attachments_with_same_placeholder() { + 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("img1.png"), + }); + composer.attached_images.push(AttachedImage { + placeholder: placeholder.clone(), + path: PathBuf::from("img2.png"), + }); + + composer.apply_external_edit(placeholder.clone()); + + assert_eq!(composer.current_text(), placeholder); + assert_eq!(composer.attached_images.len(), 1); + } + + #[test] + fn apply_external_edit_keeps_cursor_at_end() { + 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, + ); + + composer.apply_external_edit("hello\n".to_string()); + + assert_eq!(composer.current_text(), "hello\n".to_string()); + assert_eq!(composer.textarea.cursor(), "hello\n".len()); + } } diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index 11a97c783b..554a6a4abe 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -150,6 +150,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(""); @@ -161,6 +162,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, @@ -173,6 +175,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec> { newline, file_paths, paste_image, + external_editor, edit_previous, quit, Line::from(""), @@ -249,6 +252,7 @@ enum ShortcutId { InsertNewline, FilePaths, PasteImage, + ExternalEditor, EditPrevious, Quit, ShowTranscript, @@ -359,6 +363,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 { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 8a4336f6fe..fbd6278cda 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -257,6 +257,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. @@ -405,6 +419,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 3b6782d06d..7d05a92237 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 264515a6c2..445fa44484 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 8f9db3b9d9..9143757f42 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1477,6 +1477,27 @@ 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 set_footer_hint_override(&mut self, items: Option>) { + self.bottom_pane.set_footer_hint_override(items); + } + + pub(crate) fn bottom_pane_height(&self, width: u16) -> u16 { + self.bottom_pane.desired_height(width) + } + + 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!( @@ -1650,7 +1671,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/editor.rs b/codex-rs/tui/src/editor.rs new file mode 100644 index 0000000000..12027df9ff --- /dev/null +++ b/codex-rs/tui/src/editor.rs @@ -0,0 +1,159 @@ +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()); + } + + #[tokio::test] + #[cfg(unix)] + async fn run_editor_returns_none_when_file_empty() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let script_path = dir.path().join("edit.sh"); + fs::write(&script_path, "#!/bin/sh\n: > \"$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, ""); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0aa422cc61..c6eb6e0bd9 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 5502b83356..6bb91373d8 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -209,6 +209,10 @@ impl Tui { self.enhanced_keys_supported } + 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 {