diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4a0adb8de7..2287f89be7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -74,6 +74,7 @@ pub(crate) struct ChatWidgetArgs { initial_prompt: Option, initial_images: Vec, enhanced_keys_supported: bool, + auto_compact: bool, } impl App<'_> { @@ -82,6 +83,7 @@ impl App<'_> { initial_prompt: Option, initial_images: Vec, show_trust_screen: bool, + auto_compact: bool, ) -> Self { let (app_event_tx, app_event_rx) = channel(); let app_event_tx = AppEventSender::new(app_event_tx); @@ -139,6 +141,7 @@ impl App<'_> { initial_prompt, initial_images, enhanced_keys_supported, + auto_compact, }; AppState::Onboarding { screen: OnboardingScreen::new(OnboardingScreenArgs { @@ -157,6 +160,7 @@ impl App<'_> { initial_prompt, initial_images, enhanced_keys_supported, + auto_compact, ); AppState::Chat { widget: Box::new(chat_widget), @@ -319,6 +323,7 @@ impl App<'_> { None, Vec::new(), self.enhanced_keys_supported, + false, )); self.app_state = AppState::Chat { widget: new_widget }; self.app_event_tx.send(AppEvent::RequestRedraw); @@ -431,6 +436,7 @@ impl App<'_> { enhanced_keys_supported, initial_images, initial_prompt, + auto_compact, }) => { self.app_state = AppState::Chat { widget: Box::new(ChatWidget::new( @@ -439,6 +445,7 @@ impl App<'_> { initial_prompt, initial_images, enhanced_keys_supported, + auto_compact, )), } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 78506f572c..535f523d06 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -61,6 +61,7 @@ pub(crate) struct ChatComposer { pending_pastes: Vec<(String, String)>, token_usage_info: Option, has_focus: bool, + auto_compact_enabled: bool, } /// Popup state – at most one can be visible at any time. @@ -91,6 +92,7 @@ impl ChatComposer { pending_pastes: Vec::new(), token_usage_info: None, has_focus: has_input_focus, + auto_compact_enabled: false, } } @@ -198,6 +200,10 @@ impl ChatComposer { self.set_has_focus(has_focus); } + pub(crate) fn set_auto_compact_enabled(&mut self, enabled: bool) { + self.auto_compact_enabled = enabled; + } + pub(crate) fn insert_str(&mut self, text: &str) { self.textarea.insert_str(text); self.sync_command_popup(); @@ -719,9 +725,13 @@ impl WidgetRef for &ChatComposer { 100 }; hint.push(Span::from(" ")); + let style = if self.auto_compact_enabled && percent_remaining <= 20 { + Style::default().fg(Color::Red) + } else { + Style::default().add_modifier(Modifier::DIM) + }; hint.push( - Span::from(format!("{percent_remaining}% context left")) - .style(Style::default().add_modifier(Modifier::DIM)), + Span::from(format!("{percent_remaining}% context left")).style(style), ); } } @@ -766,6 +776,10 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; use crate::bottom_pane::textarea::TextArea; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Color; + use ratatui::widgets::WidgetRef; #[test] fn test_current_at_token_basic_cases() { @@ -1307,4 +1321,90 @@ mod tests { ] ); } + + #[test] + fn context_left_is_red_when_low_and_auto_compact_enabled() { + use crate::app_event::AppEvent; + use crate::bottom_pane::AppEventSender; + use codex_core::protocol::TokenUsage; + + let (tx, _rx) = std::sync::mpsc::channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new(true, sender, false); + composer.set_auto_compact_enabled(true); + + let total = TokenUsage::default(); + // Force 0% remaining (100 used of 100 window). + let last = TokenUsage { total_tokens: 100, ..Default::default() }; + composer.set_token_usage(total, last, Some(100)); + + let area = Rect::new(0, 0, 100, 4); + let mut buf = Buffer::empty(area); + (&composer).render_ref(area, &mut buf); + + // Inspect last row for the substring and verify it's red. + let y = area.y + area.height - 1; + let mut row = String::new(); + for x in area.x..(area.x + area.width) { + if let Some(cell) = buf.cell((x, y)) { + row.push_str(cell.symbol()); + } + } + let needle = "0% context left"; + let idx = match row.find(needle) { + Some(i) => i, + None => { + panic!("expected hint substring present"); + } + }; + let start = idx as u16; + let x0 = area.x + start; + // Check a couple of chars for red fg (e.g., '0' and '%'). + let fg0 = buf.cell((x0, y)).and_then(|c| c.style().fg); + let fg1 = buf.cell((x0 + 1, y)).and_then(|c| c.style().fg); + assert_eq!(fg0, Some(Color::Red)); + assert_eq!(fg1, Some(Color::Red)); + } + + #[test] + fn context_left_is_dim_when_auto_compact_disabled() { + use crate::app_event::AppEvent; + use crate::bottom_pane::AppEventSender; + use codex_core::protocol::TokenUsage; + + let (tx, _rx) = std::sync::mpsc::channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new(true, sender, false); + composer.set_auto_compact_enabled(false); + + let total = TokenUsage::default(); + let last = TokenUsage { total_tokens: 100, ..Default::default() }; // 0% remaining + composer.set_token_usage(total, last, Some(100)); + + let area = Rect::new(0, 0, 100, 4); + let mut buf = Buffer::empty(area); + (&composer).render_ref(area, &mut buf); + + let y = area.y + area.height - 1; + let mut row = String::new(); + for x in area.x..(area.x + area.width) { + if let Some(cell) = buf.cell((x, y)) { + row.push_str(cell.symbol()); + } + } + let needle = "0% context left"; + let idx = match row.find(needle) { + Some(i) => i, + None => { + panic!("expected hint substring present"); + } + }; + let start = idx as u16; + let x0 = area.x + start; + // When auto-compact is disabled, the hint should not be red. + let fg0 = buf.cell((x0, y)).and_then(|c| c.style().fg); + let fg1 = buf.cell((x0 + 1, y)).and_then(|c| c.style().fg); + assert_ne!(fg0, Some(Color::Red)); + assert_ne!(fg1, Some(Color::Red)); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 4606f9b8ee..c74a92a297 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -259,6 +259,10 @@ impl BottomPane<'_> { self.ctrl_c_quit_hint } + pub(crate) fn set_auto_compact_enabled(&mut self, enabled: bool) { + self.composer.set_auto_compact_enabled(enabled); + } + pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 173ab64af2..d88601ab56 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -82,6 +82,8 @@ pub(crate) struct ChatWidget<'a> { current_stream: Option, stream_header_emitted: bool, live_max_rows: u16, + auto_compact_enabled: bool, + pending_user_message: Option, } struct UserMessage { @@ -164,6 +166,7 @@ impl ChatWidget<'_> { initial_prompt: Option, initial_images: Vec, enhanced_keys_supported: bool, + auto_compact_enabled: bool, ) -> Self { let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); @@ -203,14 +206,17 @@ impl ChatWidget<'_> { } }); + let mut bottom_pane = BottomPane::new(BottomPaneParams { + app_event_tx: app_event_tx.clone(), + has_input_focus: true, + enhanced_keys_supported, + }); + bottom_pane.set_auto_compact_enabled(auto_compact_enabled); + Self { app_event_tx: app_event_tx.clone(), codex_op_tx, - bottom_pane: BottomPane::new(BottomPaneParams { - app_event_tx, - has_input_focus: true, - enhanced_keys_supported, - }), + bottom_pane, active_exec_cell: None, config, initial_user_message: create_initial_user_message( @@ -227,6 +233,8 @@ impl ChatWidget<'_> { current_stream: None, stream_header_emitted: false, live_max_rows: 3, + auto_compact_enabled, + pending_user_message: None, } } @@ -245,12 +253,37 @@ impl ChatWidget<'_> { match self.bottom_pane.handle_key_event(key_event) { InputResult::Submitted(text) => { - self.submit_user_message(text.into()); + self.on_user_submit(text); } InputResult::None => {} } } + /// Handle a user submission, optionally triggering auto‑compact when the + /// remaining model context is 0%. + fn on_user_submit(&mut self, text: String) { + let message: UserMessage = text.into(); + if self.auto_compact_enabled { + if let Some(0) = self.percent_remaining() { + self.pending_user_message = Some(message); + self.clear_token_usage(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); + return; + } + } + self.submit_user_message(message); + } + + fn percent_remaining(&self) -> Option { + let context_window = self.config.model_context_window?; + if context_window == 0 { + return Some(100); + } + let used = self.last_token_usage.tokens_in_context_window() as f32; + let percent = 100.0f32 - (used / (context_window as f32)) * 100.0; + Some(percent.clamp(0.0, 100.0) as u8) + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } @@ -389,6 +422,10 @@ impl ChatWidget<'_> { self.bottom_pane.set_task_running(false); self.bottom_pane.clear_live_ring(); self.request_redraw(); + + if let Some(msg) = self.pending_user_message.take() { + self.submit_user_message(msg); + } } EventMsg::TokenCount(token_usage) => { self.total_token_usage = add_token_usage(&self.total_token_usage, &token_usage); diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 91ee9cfdc7..d5a811d02b 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -40,6 +40,10 @@ pub struct Cli { #[arg(long = "full-auto", default_value_t = false)] pub full_auto: bool, + /// Automatically run /compact when the remaining model context is 0%. + #[arg(long = "auto-compact", default_value_t = false)] + pub auto_compact: bool, + /// Skip all confirmation prompts and execute commands without sandboxing. /// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed. #[arg( diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 27c850ca61..771c04f8b3 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -266,8 +266,19 @@ fn run_ratatui_app( let mut terminal = tui::init(&config)?; terminal.clear()?; - let Cli { prompt, images, .. } = cli; - let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen); + let Cli { + prompt, + images, + auto_compact, + .. + } = cli; + let mut app = App::new( + config.clone(), + prompt, + images, + should_show_trust_screen, + auto_compact, + ); // Bridge log receiver into the AppEvent channel so latest log lines update the UI. {