Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pub(crate) struct ChatWidgetArgs {
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
auto_compact: bool,
}

impl App<'_> {
Expand All @@ -82,6 +83,7 @@ impl App<'_> {
initial_prompt: Option<String>,
initial_images: Vec<std::path::PathBuf>,
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);
Expand Down Expand Up @@ -139,6 +141,7 @@ impl App<'_> {
initial_prompt,
initial_images,
enhanced_keys_supported,
auto_compact,
};
AppState::Onboarding {
screen: OnboardingScreen::new(OnboardingScreenArgs {
Expand All @@ -157,6 +160,7 @@ impl App<'_> {
initial_prompt,
initial_images,
enhanced_keys_supported,
auto_compact,
);
AppState::Chat {
widget: Box::new(chat_widget),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -439,6 +445,7 @@ impl App<'_> {
initial_prompt,
initial_images,
enhanced_keys_supported,
auto_compact,
)),
}
}
Expand Down
104 changes: 102 additions & 2 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ pub(crate) struct ChatComposer {
pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
auto_compact_enabled: bool,
}

/// Popup state – at most one can be visible at any time.
Expand Down Expand Up @@ -91,6 +92,7 @@ impl ChatComposer {
pending_pastes: Vec::new(),
token_usage_info: None,
has_focus: has_input_focus,
auto_compact_enabled: false,
}
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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),
);
}
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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::<AppEvent>();
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::<AppEvent>();
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));
}
}
4 changes: 4 additions & 0 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
49 changes: 43 additions & 6 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ pub(crate) struct ChatWidget<'a> {
current_stream: Option<StreamKind>,
stream_header_emitted: bool,
live_max_rows: u16,
auto_compact_enabled: bool,
pending_user_message: Option<UserMessage>,
}

struct UserMessage {
Expand Down Expand Up @@ -164,6 +166,7 @@ impl ChatWidget<'_> {
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
auto_compact_enabled: bool,
) -> Self {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();

Expand Down Expand Up @@ -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(
Expand All @@ -227,6 +233,8 @@ impl ChatWidget<'_> {
current_stream: None,
stream_header_emitted: false,
live_max_rows: 3,
auto_compact_enabled,
pending_user_message: None,
}
}

Expand All @@ -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<u8> {
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);
}
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/tui/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 13 additions & 2 deletions codex-rs/tui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
{
Expand Down
Loading