Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1008,6 +1011,70 @@ 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;
}
};

let seed = self.chat_widget.composer_text_with_pending();

// 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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does this look like if EDITOR="code --wait"?

Copy link
Author

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what does the terminal window look like while the editor is running?

Copy link
Author

Choose a reason for hiding this comment

The 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 code window is open and add text to the prompt, that text is retained (the resulting text from the code window is prepended to the text you added). but this seems like an edge case.

Copy link
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this should behave roughly the same as ^Z.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an approval dialog is showing

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.

or you're in transcript mode

the Ctrl+G shortcut is gated on there not being an overlay active, so you cannot open the external editor if transcript mode or another overlay is open.

what happens if you open a terminal editor like vim while a turn is running

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 Ctrl+Z approach, we wouldn't be able to open the external editor mid-turn, which could disappoint users who expect to be able to write their next prompt while inference runs for the previous one. FWIW, Claude Code allows launching the external editor while a turn is in progress.

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}");
}

if was_alt_screen {
let _ = tui.enter_alt_screen();
}

match editor_result {
Ok(Some(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();
}
Ok(None) => {
// Empty file clears the composer.
self.chat_widget.apply_external_edit(String::new());
tui.frame_requester().schedule_frame();
}
Err(err) => {
self.chat_widget
.add_to_history(history_cell::new_error_event(format!(
"Failed to open editor: {err}",
)));
}
}
}

async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
match key_event {
KeyEvent {
Expand All @@ -1021,6 +1088,16 @@ 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,
..
} => {
if self.overlay.is_none() {
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)
Expand Down
238 changes: 238 additions & 0 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,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<String, usize> = 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<Vec<(String, String)>>) {
Expand Down Expand Up @@ -3876,4 +3955,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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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::<AppEvent>();
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());
}
}
13 changes: 13 additions & 0 deletions codex-rs/tui/src/bottom_pane/footer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
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("");
Expand All @@ -161,6 +162,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
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,
Expand All @@ -173,6 +175,7 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
newline,
file_paths,
paste_image,
external_editor,
edit_previous,
quit,
Line::from(""),
Expand Down Expand Up @@ -249,6 +252,7 @@ enum ShortcutId {
InsertNewline,
FilePaths,
PasteImage,
ExternalEditor,
EditPrevious,
Quit,
ShowTranscript,
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading