diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index 4675d78f2b..b24410da1e 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -308,42 +308,11 @@ impl App { } let app_keymap_shortcuts_available = self.app_keymap_shortcuts_available(); - - let side_toggle_bindings = &self.keymap.app.toggle_side_conversation; if app_keymap_shortcuts_available - && (side_toggle_bindings.is_pressed(key_event) - || side_toggle_bindings.contains(&crate::key_hint::ctrl(KeyCode::Char('/'))) - && crate::key_hint::ctrl(KeyCode::Char('7')).is_press(key_event)) + && self + .handle_shared_app_keymap_action(tui, app_server, key_event) + .await { - if let Err(err) = self.toggle_side_conversation(tui, app_server).await { - self.chat_widget - .add_error_message(format!("Failed to switch side conversation: {err}")); - } - return; - } - - if app_keymap_shortcuts_available && self.keymap.app.toggle_vim_mode.is_pressed(key_event) { - self.chat_widget.toggle_vim_mode_and_notify(); - return; - } - - if app_keymap_shortcuts_available - && self.keymap.app.toggle_fast_mode.is_pressed(key_event) - && self.chat_widget.can_toggle_fast_mode_from_keybinding() - { - self.chat_widget.toggle_fast_mode_from_ui(); - return; - } - - if app_keymap_shortcuts_available && self.keymap.app.toggle_raw_output.is_pressed(key_event) - { - let enabled = !self.chat_widget.raw_output_mode(); - self.apply_raw_output_mode(tui, enabled, /*notify*/ false); - return; - } - - if app_keymap_shortcuts_available && self.keymap.app.open_agents.is_pressed(key_event) { - self.open_agents_overview(app_server); return; } @@ -356,27 +325,7 @@ impl App { return; } - if app_keymap_shortcuts_available - && self.keymap.app.open_external_editor.is_pressed(key_event) - { - // 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); - } - return; - } - - if !self.chat_widget.has_active_view() - && self - .current_displayed_thread_id() - .is_some_and(|id| self.thread_unavailable(id)) - && !(key_event.modifiers.contains(KeyModifiers::CONTROL) - && matches!(key_event.code, KeyCode::Char('c' | 'd'))) - { + if self.should_handle_unavailable_thread_key(key_event) { self.chat_widget.handle_disconnected_key(key_event); return; } @@ -399,22 +348,6 @@ impl App { } match key_event { - _ if app_keymap_shortcuts_available - && self.keymap.app.clear_terminal.is_pressed(key_event) => - { - if !self.chat_widget.can_run_ctrl_l_clear_now() { - return; - } - if let Err(err) = self.clear_terminal_ui(tui, /*redraw_header*/ false) { - tracing::warn!(error = %err, "failed to clear terminal UI"); - self.chat_widget - .add_error_message(format!("Failed to clear terminal UI: {err}")); - } else { - self.reset_app_ui_state_after_clear(); - self.queue_clear_ui_header(tui); - tui.frame_requester().schedule_frame(); - } - } // Enter confirms backtrack when primed + count > 0. Otherwise pass to widget. KeyEvent { code: KeyCode::Enter, @@ -447,6 +380,89 @@ impl App { }; } + pub(crate) async fn handle_shared_app_keymap_action( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + key_event: KeyEvent, + ) -> bool { + let side_toggle_bindings = &self.keymap.app.toggle_side_conversation; + if side_toggle_bindings.is_pressed(key_event) + || side_toggle_bindings.contains(&crate::key_hint::ctrl(KeyCode::Char('/'))) + && crate::key_hint::ctrl(KeyCode::Char('7')).is_press(key_event) + { + if let Err(err) = self.toggle_side_conversation(tui, app_server).await { + self.chat_widget + .add_error_message(format!("Failed to switch side conversation: {err}")); + } + return true; + } + + if self.keymap.app.toggle_vim_mode.is_pressed(key_event) { + self.chat_widget.toggle_vim_mode_and_notify(); + return true; + } + + if self.keymap.app.toggle_fast_mode.is_pressed(key_event) + && self.chat_widget.can_toggle_fast_mode_from_keybinding() + { + self.chat_widget.toggle_fast_mode_from_ui(); + return true; + } + + if self.keymap.app.toggle_raw_output.is_pressed(key_event) { + let enabled = !self.chat_widget.raw_output_mode(); + self.apply_raw_output_mode(tui, enabled, /*notify*/ false); + return true; + } + + if self.keymap.app.open_agents.is_pressed(key_event) { + self.open_agents_overview(app_server); + return true; + } + + if self.keymap.app.open_external_editor.is_pressed(key_event) { + 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); + } + return true; + } + + if self.keymap.app.clear_terminal.is_pressed(key_event) { + // Leave cached history intact and let the unavailable-thread input path handle this key. + if self.should_handle_unavailable_thread_key(key_event) { + return false; + } + if !self.chat_widget.can_run_ctrl_l_clear_now() { + return true; + } + if let Err(err) = self.clear_terminal_ui(tui, /*redraw_header*/ false) { + tracing::warn!(error = %err, "failed to clear terminal UI"); + self.chat_widget + .add_error_message(format!("Failed to clear terminal UI: {err}")); + } else { + self.reset_app_ui_state_after_clear(); + self.queue_clear_ui_header(tui); + tui.frame_requester().schedule_frame(); + } + return true; + } + + false + } + + fn should_handle_unavailable_thread_key(&self, key_event: KeyEvent) -> bool { + !self.chat_widget.has_active_view() + && self + .current_displayed_thread_id() + .is_some_and(|id| self.thread_unavailable(id)) + && !(key_event.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key_event.code, KeyCode::Char('c' | 'd'))) + } + pub(super) fn should_handle_backtrack_esc(&self, key_event: KeyEvent) -> bool { !self.chat_widget.side_conversation_active() && self.chat_widget.is_normal_backtrack_mode() diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 3a9b3db5a9..6971dab720 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -38,6 +38,8 @@ mod startup; mod stream_animation_tests; #[path = "tests/thread_usage.rs"] mod thread_usage; +#[path = "tests/transcript_composer.rs"] +mod transcript_composer; #[path = "tests/turn_submission.rs"] mod turn_submission; diff --git a/codex-rs/tui/src/app/tests/key_chords.rs b/codex-rs/tui/src/app/tests/key_chords.rs index 62210a3476..647aa437c7 100644 --- a/codex-rs/tui/src/app/tests/key_chords.rs +++ b/codex-rs/tui/src/app/tests/key_chords.rs @@ -115,6 +115,30 @@ async fn completed_global_chord_reuses_the_existing_action_handler() -> Result<( Ok(()) } +#[tokio::test] +async fn completed_global_chords_toggle_output_and_request_external_editor() -> Result<()> { + let (mut app, mut tui, mut app_server) = chord_app().await?; + let config = toml::from_str( + "[global]\ntoggle_raw_output = [\"ctrl-x r\"]\nopen_external_editor = [\"ctrl-x e\"]", + )?; + app.keymap = RuntimeKeymap::from_config(&config).expect("valid global chords"); + app.chat_widget.apply_keymap_update(config, &app.keymap); + + for key in [ctrl('x'), KeyCode::Char('r').into()] { + press(&mut app, &mut tui, &mut app_server, key).await?; + } + assert!(app.chat_widget.raw_output_mode()); + + for key in [ctrl('x'), KeyCode::Char('e').into()] { + press(&mut app, &mut tui, &mut app_server, key).await?; + } + assert_eq!( + app.chat_widget.external_editor_state(), + super::ExternalEditorState::Requested + ); + Ok(()) +} + #[tokio::test] async fn wrong_second_stroke_passes_through_but_escape_is_consumed() -> Result<()> { let (mut app, mut tui, mut app_server) = chord_app().await?; diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index d8b59813c2..84f3728d96 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -390,7 +390,19 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?; assert!(history.contains("Cached previous conversation")); - let content = &history[history.find("Cached previous conversation").unwrap()..]; + app.handle_tui_event( + &mut tui, + &mut session, + TuiEvent::Key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)), + ) + .await?; + let preserved_history = + drain_history(&mut app, &mut tui, &mut session, &mut events).await?; + assert_eq!(preserved_history, history); + + let content = &preserved_history[preserved_history + .find("Cached previous conversation") + .unwrap()..]; assert_snapshot!( "reconnected_unavailable_conversation", format!( diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap new file mode 100644 index 0000000000..8a310ff6bc --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__transcript_composer__transcript_flag_off_viewer.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/app/tests/transcript_composer.rs +expression: text +--- +/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / / + +› first + + + +› second + +───────────────────────────────────────────────────────────────────────────────────────────── 100% ─ + ↑/↓ to scroll pgup/pgdn to page home/end to jump + q to quit esc to edit prev diff --git a/codex-rs/tui/src/app/tests/transcript_composer.rs b/codex-rs/tui/src/app/tests/transcript_composer.rs new file mode 100644 index 0000000000..358e05d031 --- /dev/null +++ b/codex-rs/tui/src/app/tests/transcript_composer.rs @@ -0,0 +1,109 @@ +//! Regression coverage for transcript viewer input and prompt selection. +//! +//! The default-off feature must leave the existing viewer and its draft intact. + +use super::*; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyModifiers; +use pretty_assertions::assert_eq; + +async fn press_key( + app: &mut App, + tui: &mut crate::tui::Tui, + app_server: &mut AppServerSession, + code: KeyCode, +) -> Result<()> { + app.handle_tui_event( + tui, + app_server, + TuiEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)), + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn transcript_flag_off_preserves_viewer_and_backtracking() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let keymap_config = toml::from_str("[composer]\nsubmit = [\"ctrl-x enter\"]")?; + app.keymap = + crate::keymap::RuntimeKeymap::from_config(&keymap_config).expect("valid composer chord"); + app.chat_widget + .apply_keymap_update(keymap_config, &app.keymap); + let mut app_server = start_config_write_test_app_server(&app).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + let session = test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()); + app.chat_widget.handle_thread_session(session); + app.transcript_cells = ["first", "second"] + .map(|message| { + Arc::new(UserHistoryCell { + message: message.into(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc + }) + .to_vec(); + app.chat_widget + .apply_external_edit("preserved draft".into()); + app.open_transcript_overlay(&mut tui); + for event in [ + TuiEvent::Paste("not composer input".into()), + TuiEvent::Key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)), + ] { + app.handle_tui_event(&mut tui, &mut app_server, event) + .await?; + } + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "preserved draft" + ); + let chord_prefix = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL); + app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(chord_prefix)) + .await?; + assert!(!app.key_chord_matcher.is_pending()); + assert!(!app.backtrack.overlay_preview_active); + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 100, /*height*/ 12, + ); + let mut buffer = ratatui::buffer::Buffer::empty(area); + let Some(Overlay::Transcript(overlay)) = &mut app.overlay else { + panic!("viewer closed") + }; + overlay.render(area, &mut buffer); + let text = buffer + .content() + .chunks(usize::from(area.width)) + .map(|row| { + row.iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim_end() + .to_string() + }) + .collect::>() + .join("\n"); + insta::assert_snapshot!("transcript_flag_off_viewer", text); + for (key, selected) in [ + (KeyCode::Esc, 1), + (KeyCode::Esc, 0), + (KeyCode::Right, 1), + (KeyCode::Right, 1), + ] { + press_key(&mut app, &mut tui, &mut app_server, key).await?; + assert_eq!(app.backtrack.nth_user_message, selected); + } + press_key(&mut app, &mut tui, &mut app_server, KeyCode::Enter).await?; + assert!(app.overlay.is_none()); + assert!( + std::iter::from_fn(|| app_event_rx.try_recv().ok()).any(|event| matches!( + event, + AppEvent::ForkSessionForPromptEdit { + nth_user_message: 1, + .. + } + )) + ); + Ok(()) +} diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 6f36701033..242b0215c6 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -21,6 +21,8 @@ //! `TranscriptOverlay::sync_live_tail`. This preserves the invariant that the overlay reflects //! both committed history and in-flight activity without changing flush or coalescing behavior. +mod legacy_input; + use std::any::TypeId; use std::sync::Arc; @@ -93,79 +95,7 @@ impl App { app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { - if let TuiEvent::Key(key_event) = &event - && let Some(Overlay::Transcript(overlay)) = self.overlay.as_ref() - && (overlay.should_load_older(*key_event) - || (self.backtrack.overlay_preview_active - && self.backtrack.nth_user_message == 0 - && matches!(key_event.code, KeyCode::Esc | KeyCode::Left) - && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat))) - && let Some(thread_id) = self.chat_widget.thread_id() - && app_server.has_older_history(thread_id) - && self.request_older_history_page(app_server, thread_id) - { - if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { - overlay.set_history_state(if overlay.should_load_from_start(*key_event) { - TranscriptHistoryState::LoadingBeginning - } else { - TranscriptHistoryState::LoadingOlder - }); - } - tui.frame_requester().schedule_frame(); - } - if self.backtrack.overlay_preview_active { - match event { - TuiEvent::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack(tui, event)?; - Ok(true) - } - TuiEvent::Key(KeyEvent { - code: KeyCode::Left, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack(tui, event)?; - Ok(true) - } - TuiEvent::Key(KeyEvent { - code: KeyCode::Right, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) => { - self.overlay_step_backtrack_forward(tui, event)?; - Ok(true) - } - TuiEvent::Key(KeyEvent { - code: KeyCode::Enter, - kind: KeyEventKind::Press, - .. - }) => { - self.overlay_confirm_backtrack(tui); - Ok(true) - } - _ => { - self.overlay_forward_event(tui, event)?; - Ok(true) - } - } - } else if let TuiEvent::Key(KeyEvent { - code: KeyCode::Esc, - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - }) = event - { - // First Esc in transcript overlay: begin backtrack preview at latest user message. - self.begin_overlay_backtrack_preview(tui); - Ok(true) - } else { - // Not in backtrack mode: forward events to the overlay widget. - self.overlay_forward_event(tui, event)?; - Ok(true) - } + self.handle_legacy_transcript_event(tui, app_server, event) } /// Handle global Esc presses for backtracking when no overlay is present. diff --git a/codex-rs/tui/src/app_backtrack/legacy_input.rs b/codex-rs/tui/src/app_backtrack/legacy_input.rs new file mode 100644 index 0000000000..c36e83bd76 --- /dev/null +++ b/codex-rs/tui/src/app_backtrack/legacy_input.rs @@ -0,0 +1,74 @@ +//! Input routing for the read-only transcript viewer. +//! +//! Preview selection is shared with the interactive transcript path so both +//! modes retain the same prompt navigation and confirmation behavior. + +use super::*; + +impl App { + pub(super) fn handle_legacy_transcript_event( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + event: TuiEvent, + ) -> Result { + if let TuiEvent::Key(key_event) = &event + && let Some(Overlay::Transcript(overlay)) = self.overlay.as_ref() + && (overlay.should_load_older(*key_event) + || (self.backtrack.overlay_preview_active + && self.backtrack.nth_user_message == 0 + && matches!(key_event.code, KeyCode::Esc | KeyCode::Left) + && matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat))) + && let Some(thread_id) = self.chat_widget.thread_id() + && app_server.has_older_history(thread_id) + && self.request_older_history_page(app_server, thread_id) + { + if let Some(Overlay::Transcript(overlay)) = self.overlay.as_mut() { + overlay.set_history_state(if overlay.should_load_from_start(*key_event) { + TranscriptHistoryState::LoadingBeginning + } else { + TranscriptHistoryState::LoadingOlder + }); + } + tui.frame_requester().schedule_frame(); + } + if self.backtrack.overlay_preview_active { + return self.handle_backtrack_preview_event(tui, event); + } + match event { + TuiEvent::Key(KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => self.begin_overlay_backtrack_preview(tui), + event => self.overlay_forward_event(tui, event)?, + } + Ok(true) + } + + pub(super) fn handle_backtrack_preview_event( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result { + match event { + TuiEvent::Key(KeyEvent { + code: KeyCode::Esc | KeyCode::Left, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => self.overlay_step_backtrack(tui, event)?, + TuiEvent::Key(KeyEvent { + code: KeyCode::Right, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + }) => self.overlay_step_backtrack_forward(tui, event)?, + TuiEvent::Key(KeyEvent { + code: KeyCode::Enter, + kind: KeyEventKind::Press, + .. + }) => self.overlay_confirm_backtrack(tui), + event => self.overlay_forward_event(tui, event)?, + } + Ok(true) + } +}