ctrl-L (clears terminal but does not start a new chat) (#12628)

# ctrl-L

- Clears your terminal window
- Does not start a new chat
This commit is contained in:
Won Park
2026-02-24 10:03:42 -08:00
committed by GitHub
parent f6053fdfb3
commit ca556fa313
2 changed files with 118 additions and 14 deletions
+105 -14
View File
@@ -730,6 +730,15 @@ impl App {
self.clear_ui_header_lines_with_version(width, CODEX_CLI_VERSION) 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<()> { fn clear_terminal_ui(&mut self, tui: &mut tui::Tui, redraw_header: bool) -> Result<()> {
let is_alt_screen_active = tui.is_alt_screen_active(); let is_alt_screen_active = tui.is_alt_screen_active();
@@ -754,16 +763,20 @@ impl App {
self.has_emitted_history_lines = false; self.has_emitted_history_lines = false;
if redraw_header { if redraw_header {
let width = tui.terminal.last_known_screen_size.width; self.queue_clear_ui_header(tui);
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;
}
} }
Ok(()) 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) { async fn shutdown_current_thread(&mut self) {
if let Some(thread_id) = self.chat_widget.thread_id() { if let Some(thread_id) = self.chat_widget.thread_id() {
// Clear any in-flight rollback guard when switching threads. // Clear any in-flight rollback guard when switching threads.
@@ -1616,12 +1629,7 @@ impl App {
} }
AppEvent::ClearUi => { AppEvent::ClearUi => {
self.clear_terminal_ui(tui, false)?; self.clear_terminal_ui(tui, false)?;
self.overlay = None; self.reset_app_ui_state_after_clear();
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.start_fresh_session_with_summary_hint(tui).await; 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())); self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
tui.frame_requester().schedule_frame(); 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 { KeyEvent {
code: KeyCode::Char('g'), code: KeyCode::Char('g'),
modifiers: crossterm::event::KeyModifiers::CONTROL, modifiers: crossterm::event::KeyModifiers::CONTROL,
@@ -3538,8 +3565,7 @@ mod tests {
Ok(()) Ok(())
} }
#[tokio::test] async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String {
async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() {
let mut app = make_test_app().await; let mut app = make_test_app().await;
app.config.cwd = PathBuf::from("/tmp/project"); app.config.cwd = PathBuf::from("/tmp/project");
app.chat_widget.set_model("gpt-test"); app.chat_widget.set_model("gpt-test");
@@ -3644,6 +3670,18 @@ mod tests {
!rendered.contains("Bracken Ferry"), !rendered.contains("Bracken Ferry"),
"clear header should not replay prior conversation turns" "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); 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<dyn HistoryCell>];
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] #[tokio::test]
async fn session_summary_skip_zero_usage() { async fn session_summary_skip_zero_usage() {
assert!(session_summary(TokenUsage::default(), None, None).is_none()); assert!(session_summary(TokenUsage::default(), None, None).is_none());
+13
View File
@@ -3403,6 +3403,19 @@ impl ChatWidget {
self.bottom_pane.can_launch_external_editor() 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) { fn dispatch_command(&mut self, cmd: SlashCommand) {
if !cmd.available_during_task() && self.bottom_pane.is_task_running() { if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
let message = format!( let message = format!(