-
Notifications
You must be signed in to change notification settings - Fork 6.7k
feat: open prompt in configured external editor #7606
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
388e21d
b20290d
f83aa7f
ca9b2a8
4819fbf
f277011
2e3d333
d0a88fa
845c59f
83c30be
a9e7147
cb82cbd
3359406
682ae9d
8499e00
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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_config; | ||
|
|
@@ -42,9 +46,12 @@ 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::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; | ||
|
|
@@ -57,10 +64,12 @@ 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."; | ||
| 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]; | ||
|
|
||
|
|
@@ -447,6 +456,10 @@ impl App { | |
| tui: &mut tui::Tui, | ||
| event: TuiEvent, | ||
| ) -> Result<bool> { | ||
| // 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 { | ||
|
|
@@ -479,6 +492,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); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -728,6 +747,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); | ||
| } | ||
|
|
@@ -1028,6 +1058,177 @@ 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(); | ||
| 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what does this look like if
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tested this, it opens a window as expected, and the text propagates back to the composer on save+close.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what does the terminal window look like while the editor is running?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it looks "the same" (just as if you tabbed away from the terminal, so the cursor isn't blinking). if you tab back into the terminal while the
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hm... but didn't we disable all our terminal modes? we shouldn't be running the TUI at the same time as the editor is open, I think. e.g. what happens if you end up in an odd state, e.g. an approval dialog is showing, or you're in transcript mode?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also consider what happens if you open a terminal editor like vim while a turn is running.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i think this should behave roughly the same as
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
yeah, you can still open + use the editor while an approval dialog is showing, and the results will persist to the prompt editor, but you won't see that until you dismiss the dialog. I think we could easily block opening the external editor while a dialog is displayed.
the
this is allowed, and we block on the terminal editor closing, so new events (like SSE updates) are accumulated but not processed in the main loop. Once you exit the terminal editor, they're processed and the TUI updates. If we switched to the I think we could go either way. Thoughts? |
||
|
|
||
| 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::flush_terminal_input_buffer(); | ||
| let clear_from_row = self | ||
| .pane_rows(tui) | ||
| .map(|(start, height)| start.saturating_add(height.saturating_sub(1))); | ||
| self.clear_bottom_pane_rows(tui, clear_from_row); | ||
| 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() {} | ||
|
|
||
| /// 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<u16>) { | ||
| 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}"); | ||
| } | ||
| } | ||
|
|
||
| /// 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)) | ||
| } | ||
|
|
||
| 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 { | ||
|
|
@@ -1041,6 +1242,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) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.