diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 160db04ce..62002c55c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -730,6 +730,15 @@ impl App { self.clear_ui_header_lines_with_version(width, CODEX_CLI_VERSION) } + fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) { + let width = tui.terminal.last_known_screen_size.width; + let header_lines = self.clear_ui_header_lines(width); + if !header_lines.is_empty() { + tui.insert_history_lines(header_lines); + self.has_emitted_history_lines = true; + } + } + fn clear_terminal_ui(&mut self, tui: &mut tui::Tui, redraw_header: bool) -> Result<()> { let is_alt_screen_active = tui.is_alt_screen_active(); @@ -754,16 +763,20 @@ impl App { self.has_emitted_history_lines = false; if redraw_header { - let width = tui.terminal.last_known_screen_size.width; - let header_lines = self.clear_ui_header_lines(width); - if !header_lines.is_empty() { - tui.insert_history_lines(header_lines); - self.has_emitted_history_lines = true; - } + self.queue_clear_ui_header(tui); } Ok(()) } + fn reset_app_ui_state_after_clear(&mut self) { + self.overlay = None; + self.transcript_cells.clear(); + self.deferred_history_lines.clear(); + self.has_emitted_history_lines = false; + self.backtrack = BacktrackState::default(); + self.backtrack_render_pending = false; + } + async fn shutdown_current_thread(&mut self) { if let Some(thread_id) = self.chat_widget.thread_id() { // Clear any in-flight rollback guard when switching threads. @@ -1616,12 +1629,7 @@ impl App { } AppEvent::ClearUi => { self.clear_terminal_ui(tui, false)?; - self.overlay = None; - self.transcript_cells.clear(); - self.deferred_history_lines.clear(); - self.has_emitted_history_lines = false; - self.backtrack = BacktrackState::default(); - self.backtrack_render_pending = false; + self.reset_app_ui_state_after_clear(); self.start_fresh_session_with_summary_hint(tui).await; } @@ -3111,6 +3119,25 @@ impl App { self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone())); tui.frame_requester().schedule_frame(); } + KeyEvent { + code: KeyCode::Char('l'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + if !self.chat_widget.can_run_ctrl_l_clear_now() { + return; + } + if let Err(err) = self.clear_terminal_ui(tui, 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(); + } + } KeyEvent { code: KeyCode::Char('g'), modifiers: crossterm::event::KeyModifiers::CONTROL, @@ -3538,8 +3565,7 @@ mod tests { Ok(()) } - #[tokio::test] - async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { + async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { let mut app = make_test_app().await; app.config.cwd = PathBuf::from("/tmp/project"); app.chat_widget.set_model("gpt-test"); @@ -3644,6 +3670,18 @@ mod tests { !rendered.contains("Bracken Ferry"), "clear header should not replay prior conversation turns" ); + rendered + } + + #[tokio::test] + async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { + let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; + assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); + } + + #[tokio::test] + async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() { + let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); } @@ -4519,6 +4557,59 @@ mod tests { } } + #[tokio::test] + async fn clear_only_ui_reset_preserves_chat_session_state() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.chat_widget.handle_codex_event(Event { + id: String::new(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + thread_name: Some("keep me".to_string()), + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + cwd: PathBuf::from("/tmp/project"), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }), + }); + app.chat_widget + .apply_external_edit("draft prompt".to_string()); + app.transcript_cells = vec![Arc::new(UserHistoryCell { + message: "old message".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc]; + app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone())); + app.deferred_history_lines = vec![Line::from("stale buffered line")]; + app.has_emitted_history_lines = true; + app.backtrack.primed = true; + app.backtrack.overlay_preview_active = true; + app.backtrack.nth_user_message = 0; + app.backtrack_render_pending = true; + + app.reset_app_ui_state_after_clear(); + + assert!(app.overlay.is_none()); + assert!(app.transcript_cells.is_empty()); + assert!(app.deferred_history_lines.is_empty()); + assert!(!app.has_emitted_history_lines); + assert!(!app.backtrack.primed); + assert!(!app.backtrack.overlay_preview_active); + assert!(app.backtrack.pending_rollback.is_none()); + assert!(!app.backtrack_render_pending); + assert_eq!(app.chat_widget.thread_id(), Some(thread_id)); + assert_eq!(app.chat_widget.composer_text_with_pending(), "draft prompt"); + } + #[tokio::test] async fn session_summary_skip_zero_usage() { assert!(session_summary(TokenUsage::default(), None, None).is_none()); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 60ac79c56..a67cc2d4b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3403,6 +3403,19 @@ impl ChatWidget { self.bottom_pane.can_launch_external_editor() } + pub(crate) fn can_run_ctrl_l_clear_now(&mut self) -> bool { + // Ctrl+L is not a slash command, but it follows /clear's current rule: + // block while a task is running. + if !self.bottom_pane.is_task_running() { + return true; + } + + let message = "Ctrl+L is disabled while a task is in progress.".to_string(); + self.add_to_history(history_cell::new_error_event(message)); + self.request_redraw(); + false + } + fn dispatch_command(&mut self, cmd: SlashCommand) { if !cmd.available_during_task() && self.bottom_pane.is_task_running() { let message = format!(