From 82d3c9ed768a16d3c86ce112c0e731e3ca091e32 Mon Sep 17 00:00:00 2001 From: Won Park Date: Sat, 21 Feb 2026 22:06:56 -0800 Subject: [PATCH] feat(tui) /clear (#12444) # /clear feature! /clear will clear your terminal while preserving the context/state of the thread. --- codex-rs/tui/src/app.rs | 173 ++++++++++++++++++ codex-rs/tui/src/app_event.rs | 3 + .../tui/src/bottom_pane/slash_commands.rs | 8 + codex-rs/tui/src/chatwidget.rs | 4 +- codex-rs/tui/src/chatwidget/tests.rs | 30 +++ codex-rs/tui/src/custom_terminal.rs | 55 +++++- codex-rs/tui/src/insert_history.rs | 3 + codex-rs/tui/src/session_log.rs | 8 + codex-rs/tui/src/slash_command.rs | 3 + ...ter_long_transcript_fresh_header_only.snap | 11 ++ codex-rs/tui/src/tui.rs | 4 + 11 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__tests__clear_ui_after_long_transcript_fresh_header_only.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 22ceed1ff..e080b8cde 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -30,6 +30,7 @@ use crate::resume_picker::SessionSelection; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; +use crate::version::CODEX_CLI_VERSION; use codex_ansi_escape::ansi_escape_line; use codex_app_server_protocol::ConfigLayerSource; use codex_core::AuthManager; @@ -680,6 +681,65 @@ impl App { .add_info_message(format!("Opened {url} in your browser."), None); } + fn clear_ui_header_lines_with_version( + &self, + width: u16, + version: &'static str, + ) -> Vec> { + history_cell::SessionHeaderHistoryCell::new( + self.chat_widget.current_model().to_string(), + self.chat_widget.current_reasoning_effort(), + self.config.cwd.clone(), + version, + ) + .display_lines(width) + } + + fn clear_ui_header_lines(&self, width: u16) -> Vec> { + self.clear_ui_header_lines_with_version(width, CODEX_CLI_VERSION) + } + + fn clear_terminal_ui(&mut self, tui: &mut tui::Tui) -> Result<()> { + let is_alt_screen_active = tui.is_alt_screen_active(); + let use_apple_terminal_clear_workaround = !is_alt_screen_active + && matches!( + codex_core::terminal::terminal_info().name, + codex_core::terminal::TerminalName::AppleTerminal + ); + + // Drop queued history insertions so stale transcript lines cannot be flushed after /clear. + tui.clear_pending_history_lines(); + + if is_alt_screen_active { + tui.terminal.clear_visible_screen()?; + } else if use_apple_terminal_clear_workaround { + // Terminal.app can leave mixed old/new glyphs behind when we purge + clear. + // Use a stricter ANSI reset, then redraw only a fresh session header box instead of + // replaying the initialization transcript preamble. + tui.terminal.clear_scrollback_and_visible_screen_ansi()?; + } else { + tui.terminal.clear_scrollback()?; + tui.terminal.clear_visible_screen()?; + } + + let mut area = tui.terminal.viewport_area; + if area.y > 0 { + // After a full clear, anchor the inline viewport at the top and redraw a fresh header + // box. `insert_history_lines()` will shift the viewport down by the rendered height. + area.y = 0; + tui.terminal.set_viewport_area(area); + } + self.has_emitted_history_lines = false; + + 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; + } + Ok(()) + } + 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. @@ -1459,6 +1519,10 @@ impl App { } tui.frame_requester().schedule_frame(); } + AppEvent::ClearUi => { + self.clear_terminal_ui(tui)?; + tui.frame_requester().schedule_frame(); + } AppEvent::OpenResumePicker => { match crate::resume_picker::run_resume_picker(tui, &self.config, false).await? { SessionSelection::Resume(path) => { @@ -3330,6 +3394,115 @@ mod tests { Ok(()) } + #[tokio::test] + async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { + let mut app = make_test_app().await; + app.config.cwd = PathBuf::from("/tmp/project"); + app.chat_widget.set_model("gpt-test"); + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::High)); + let story_part_one = "In the cliffside town of Bracken Ferry, the lighthouse had been dark for \ + nineteen years, and the children were told it was because the sea no longer wanted a \ + guide. Mara, who repaired clocks for a living, found that hard to believe. Every dawn she \ + heard the gulls circling the empty tower, and every dusk she watched ships hesitate at the \ + mouth of the bay as if listening for a signal that never came. When an old brass key fell \ + out of a cracked parcel in her workshop, tagged only with the words 'for the lamp room,' \ + she decided to climb the hill and see what the town had forgotten."; + let story_part_two = "Inside the lighthouse she found gears wrapped in oilcloth, logbooks filled \ + with weather notes, and a lens shrouded beneath salt-stiff canvas. The mechanism was not \ + broken, only unfinished. Someone had removed the governor spring and hidden it in a false \ + drawer, along with a letter from the last keeper admitting he had darkened the light on \ + purpose after smugglers threatened his family. Mara spent the night rebuilding the clockwork \ + from spare watch parts, her fingers blackened with soot and grease, while a storm gathered \ + over the water and the harbor bells began to ring."; + let story_part_three = "At midnight the first squall hit, and the fishing boats returned early, \ + blind in sheets of rain. Mara wound the mechanism, set the teeth by hand, and watched the \ + great lens begin to turn in slow, certain arcs. The beam swept across the bay, caught the \ + whitecaps, and reached the boats just as they were drifting toward the rocks below the \ + eastern cliffs. In the morning the town square was crowded with wet sailors, angry elders, \ + and wide-eyed children, but when the oldest captain placed the keeper's log on the fountain \ + and thanked Mara for relighting the coast, nobody argued. By sunset, Bracken Ferry had a \ + lighthouse again, and Mara had more clocks to mend than ever because everyone wanted \ + something in town to keep better time."; + + let user_cell = |text: &str| -> Arc { + Arc::new(UserHistoryCell { + message: text.to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc + }; + let agent_cell = |text: &str| -> Arc { + Arc::new(AgentMessageCell::new( + vec![Line::from(text.to_string())], + true, + )) as Arc + }; + let make_header = |is_first| -> Arc { + let event = SessionConfiguredEvent { + session_id: ThreadId::new(), + forked_from_id: None, + thread_name: None, + 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: Some(ReasoningEffortConfig::High), + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + network_proxy: None, + rollout_path: Some(PathBuf::new()), + }; + Arc::new(new_session_info( + app.chat_widget.config_ref(), + app.chat_widget.current_model(), + event, + is_first, + None, + )) as Arc + }; + + app.transcript_cells = vec![ + make_header(true), + Arc::new(crate::history_cell::new_info_event( + "startup tip that used to replay".to_string(), + None, + )) as Arc, + user_cell("Tell me a long story about a town with a dark lighthouse."), + agent_cell(story_part_one), + user_cell("Continue the story and reveal why the light went out."), + agent_cell(story_part_two), + user_cell("Finish the story with a storm and a resolution."), + agent_cell(story_part_three), + ]; + app.has_emitted_history_lines = true; + + let rendered = app + .clear_ui_header_lines_with_version(80, "") + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + !rendered.contains("startup tip that used to replay"), + "clear header should not replay startup notices" + ); + assert!( + !rendered.contains("Bracken Ferry"), + "clear header should not replay prior conversation turns" + ); + assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); + } + async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 60c25fcc4..865ddb29e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -54,6 +54,9 @@ pub(crate) enum AppEvent { /// Start a new session. NewSession, + /// Clear the terminal UI (screen + scrollback) without changing session state. + ClearUi, + /// Open the resume picker inside the running TUI session. OpenResumePicker, diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 4613161f2..736eb3102 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -74,4 +74,12 @@ mod tests { let cmd = find_builtin_command("debug-config", true, true, true, false); assert_eq!(cmd, Some(SlashCommand::DebugConfig)); } + + #[test] + fn clear_command_resolves_for_dispatch() { + assert_eq!( + find_builtin_command("clear", true, true, true, false), + Some(SlashCommand::Clear) + ); + } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ba7013c3c..400713b22 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3309,6 +3309,9 @@ impl ChatWidget { SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } + SlashCommand::Clear => { + self.app_event_tx.send(AppEvent::ClearUi); + } SlashCommand::Resume => { self.app_event_tx.send(AppEvent::OpenResumePicker); } @@ -6437,7 +6440,6 @@ impl ChatWidget { &self.current_collaboration_mode } - #[cfg(test)] pub(crate) fn current_reasoning_effort(&self) -> Option { self.effective_reasoning_effort() } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e634d3ba3..f1f0adf2a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -4342,6 +4342,36 @@ async fn slash_clean_submits_background_terminal_cleanup() { ); } +#[tokio::test] +async fn slash_clear_requests_ui_clear_when_idle() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Clear); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); +} + +#[tokio::test] +async fn slash_clear_is_disabled_while_task_running() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_task_running(true); + + chat.dispatch_command(SlashCommand::Clear); + + let event = rx.try_recv().expect("expected disabled command error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains("'/clear' is disabled while a task is in progress."), + "expected /clear task-running error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(rx.try_recv().is_err(), "expected no follow-up events"); +} + #[tokio::test] async fn slash_memory_drop_submits_drop_memories_op() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index a749ca5d2..1b16f5915 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -155,6 +155,8 @@ where /// Last known position of the cursor. Used to find the new area when the viewport is inlined /// and the terminal resized. pub last_known_cursor_pos: Position, + /// Count of visible history rows rendered above the viewport in inline mode. + visible_history_rows: u16, } impl Drop for Terminal @@ -195,6 +197,7 @@ where viewport_area: Rect::new(0, cursor_pos.y, 0, 0), last_known_screen_size: screen_size, last_known_cursor_pos: cursor_pos, + visible_history_rows: 0, }) } @@ -262,6 +265,7 @@ where self.current_buffer_mut().resize(area); self.previous_buffer_mut().resize(area); self.viewport_area = area; + self.visible_history_rows = self.visible_history_rows.min(area.top()); } /// Queries the backend for size and resizes if it doesn't match the previous size. @@ -425,14 +429,61 @@ where if self.viewport_area.is_empty() { return Ok(()); } - self.backend - .set_cursor_position(self.viewport_area.as_position())?; + let home = Position { x: 0, y: 0 }; + // Use an explicit cursor-home around scrollback purge for terminals that + // are sensitive to inline viewport cursor placement (e.g. Terminal.app). + self.set_cursor_position(home)?; queue!(self.backend, Clear(crossterm::terminal::ClearType::Purge))?; + self.set_cursor_position(home)?; std::io::Write::flush(&mut self.backend)?; self.previous_buffer_mut().reset(); Ok(()) } + /// Clear the entire visible screen (not just the viewport) and force a full redraw. + pub fn clear_visible_screen(&mut self) -> io::Result<()> { + let home = Position { x: 0, y: 0 }; + // Some terminals (notably Terminal.app) behave more reliably if we pair ED2 + // with an explicit cursor-home before/after, matching the common `clear` + // sequence (`CSI 2J` + `CSI H`). + self.set_cursor_position(home)?; + self.backend.clear_region(ClearType::All)?; + self.set_cursor_position(home)?; + std::io::Write::flush(&mut self.backend)?; + self.visible_history_rows = 0; + self.previous_buffer_mut().reset(); + Ok(()) + } + + /// Hard-reset scrollback + visible screen using an explicit ANSI sequence. + /// + /// This is a compatibility fallback for terminals that misbehave when purge + /// and full-screen clear are issued as separate backend commands. + pub fn clear_scrollback_and_visible_screen_ansi(&mut self) -> io::Result<()> { + if self.viewport_area.is_empty() { + return Ok(()); + } + + // Reset scroll region + style state, purge scrollback, clear screen, home cursor. + write!(self.backend, "\x1b[r\x1b[0m\x1b[3J\x1b[2J\x1b[H")?; + std::io::Write::flush(&mut self.backend)?; + self.last_known_cursor_pos = Position { x: 0, y: 0 }; + self.visible_history_rows = 0; + self.previous_buffer_mut().reset(); + Ok(()) + } + + pub fn visible_history_rows(&self) -> u16 { + self.visible_history_rows + } + + pub(crate) fn note_history_rows_inserted(&mut self, inserted_rows: u16) { + self.visible_history_rows = self + .visible_history_rows + .saturating_add(inserted_rows) + .min(self.viewport_area.top()); + } + /// Clears the inactive buffer and swaps it with the current buffer pub fn swap_buffers(&mut self) { self.previous_buffer_mut().reset(); diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 9fe4316a9..e972627bc 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -174,6 +174,9 @@ where if should_update_area { terminal.set_viewport_area(area); } + if wrapped_lines > 0 { + terminal.note_history_rows_inserted(wrapped_lines); + } Ok(()) } diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index ca4d39cd1..e512c2f3c 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -136,6 +136,14 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { }); LOGGER.write_json_line(value); } + AppEvent::ClearUi => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "clear_ui", + }); + LOGGER.write_json_line(value); + } AppEvent::InsertHistoryCell(cell) => { let value = json!({ "ts": now_ts(), diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 55fc01e90..a512060e2 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -47,6 +47,7 @@ pub enum SlashCommand { Rollout, Ps, Clean, + Clear, Personality, TestApproval, // Debugging commands. @@ -67,6 +68,7 @@ impl SlashCommand { SlashCommand::Review => "review my current changes and find issues", SlashCommand::Rename => "rename the current thread", SlashCommand::Resume => "resume a saved chat", + SlashCommand::Clear => "clear the terminal screen and scrollback", SlashCommand::Fork => "fork the current chat", // SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", @@ -136,6 +138,7 @@ impl SlashCommand { | SlashCommand::Experimental | SlashCommand::Review | SlashCommand::Plan + | SlashCommand::Clear | SlashCommand::Logout | SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate => false, diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__clear_ui_after_long_transcript_fresh_header_only.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clear_ui_after_long_transcript_fresh_header_only.snap new file mode 100644 index 000000000..abb423ff1 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__clear_ui_after_long_transcript_fresh_header_only.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app.rs +assertion_line: 3452 +expression: rendered +--- +╭─────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v) │ +│ │ +│ model: gpt-test high /model to change │ +│ directory: /tmp/project │ +╰─────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index ab9c88d2a..9d521d1aa 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -445,6 +445,10 @@ impl Tui { self.frame_requester().schedule_frame(); } + pub fn clear_pending_history_lines(&mut self) { + self.pending_history_lines.clear(); + } + pub fn draw( &mut self, height: u16,