feat(tui) /clear (#12444)

# /clear feature! 

/clear will clear your terminal while preserving the context/state of
the thread.
This commit is contained in:
Won Park
2026-02-21 22:06:56 -08:00
committed by GitHub
Unverified
parent 37610240ec
commit 82d3c9ed76
11 changed files with 299 additions and 3 deletions
+173
View File
@@ -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<Line<'static>> {
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<Line<'static>> {
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<dyn HistoryCell> {
Arc::new(UserHistoryCell {
message: text.to_string(),
text_elements: Vec::new(),
local_image_paths: Vec::new(),
remote_image_urls: Vec::new(),
}) as Arc<dyn HistoryCell>
};
let agent_cell = |text: &str| -> Arc<dyn HistoryCell> {
Arc::new(AgentMessageCell::new(
vec![Line::from(text.to_string())],
true,
)) as Arc<dyn HistoryCell>
};
let make_header = |is_first| -> Arc<dyn HistoryCell> {
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<dyn HistoryCell>
};
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<dyn HistoryCell>,
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, "<VERSION>")
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.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();
+3
View File
@@ -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,
@@ -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)
);
}
}
+3 -1
View File
@@ -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<ReasoningEffortConfig> {
self.effective_reasoning_effort()
}
+30
View File
@@ -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;
+53 -2
View File
@@ -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<B> Drop for Terminal<B>
@@ -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();
+3
View File
@@ -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(())
}
+8
View File
@@ -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(),
+3
View File
@@ -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,
@@ -0,0 +1,11 @@
---
source: tui/src/app.rs
assertion_line: 3452
expression: rendered
---
╭─────────────────────────────────────────────╮
│ >_ OpenAI Codex (v<VERSION>) │
│ │
│ model: gpt-test high /model to change │
│ directory: /tmp/project │
╰─────────────────────────────────────────────╯
+4
View File
@@ -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,