mirror of
https://github.com/openai/codex.git
synced 2026-04-30 03:12:20 +03:00
When using the OpenAI Responses API, we now record the `usage` field for
a `"response.completed"` event, which includes metrics about the number
of tokens consumed. We also introduce `openai_model_info.rs`, which
includes current data about the most common OpenAI models available via
the API (specifically `context_window` and `max_output_tokens`). If
Codex does not recognize the model, you can set `model_context_window`
and `model_max_output_tokens` explicitly in `config.toml`.
When then introduce a new event type to `protocol.rs`, `TokenCount`,
which includes the `TokenUsage` for the most recent turn.
Finally, we update the TUI to record the running sum of tokens used so
the percentage of available context window remaining can be reported via
the placeholder text for the composer:

We could certainly get much fancier with this (such as reporting the
estimated cost of the conversation), but for now, we are just trying to
achieve feature parity with the TypeScript CLI.
Though arguably this improves upon the TypeScript CLI, as the TypeScript
CLI uses heuristics to estimate the number of tokens used rather than
using the `usage` information directly:
296996d74e/codex-cli/src/utils/approximate-tokens-used.ts (L3-L16)
Fixes https://github.com/openai/codex/issues/1242
215 lines
6.9 KiB
Rust
215 lines
6.9 KiB
Rust
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
|
|
|
|
use bottom_pane_view::BottomPaneView;
|
|
use bottom_pane_view::ConditionalUpdate;
|
|
use codex_core::protocol::TokenUsage;
|
|
use crossterm::event::KeyEvent;
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::widgets::WidgetRef;
|
|
|
|
use crate::app_event::AppEvent;
|
|
use crate::app_event_sender::AppEventSender;
|
|
use crate::user_approval_widget::ApprovalRequest;
|
|
|
|
mod approval_modal_view;
|
|
mod bottom_pane_view;
|
|
mod chat_composer;
|
|
mod chat_composer_history;
|
|
mod command_popup;
|
|
mod status_indicator_view;
|
|
|
|
pub(crate) use chat_composer::ChatComposer;
|
|
pub(crate) use chat_composer::InputResult;
|
|
|
|
use approval_modal_view::ApprovalModalView;
|
|
use status_indicator_view::StatusIndicatorView;
|
|
|
|
/// Pane displayed in the lower half of the chat UI.
|
|
pub(crate) struct BottomPane<'a> {
|
|
/// Composer is retained even when a BottomPaneView is displayed so the
|
|
/// input state is retained when the view is closed.
|
|
composer: ChatComposer<'a>,
|
|
|
|
/// If present, this is displayed instead of the `composer`.
|
|
active_view: Option<Box<dyn BottomPaneView<'a> + 'a>>,
|
|
|
|
app_event_tx: AppEventSender,
|
|
has_input_focus: bool,
|
|
is_task_running: bool,
|
|
}
|
|
|
|
pub(crate) struct BottomPaneParams {
|
|
pub(crate) app_event_tx: AppEventSender,
|
|
pub(crate) has_input_focus: bool,
|
|
}
|
|
|
|
impl BottomPane<'_> {
|
|
pub fn new(params: BottomPaneParams) -> Self {
|
|
Self {
|
|
composer: ChatComposer::new(params.has_input_focus, params.app_event_tx.clone()),
|
|
active_view: None,
|
|
app_event_tx: params.app_event_tx,
|
|
has_input_focus: params.has_input_focus,
|
|
is_task_running: false,
|
|
}
|
|
}
|
|
|
|
/// Forward a key event to the active view or the composer.
|
|
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> InputResult {
|
|
if let Some(mut view) = self.active_view.take() {
|
|
view.handle_key_event(self, key_event);
|
|
if !view.is_complete() {
|
|
self.active_view = Some(view);
|
|
} else if self.is_task_running {
|
|
let height = self.composer.calculate_required_height(&Rect::default());
|
|
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
|
self.app_event_tx.clone(),
|
|
height,
|
|
)));
|
|
}
|
|
self.request_redraw();
|
|
InputResult::None
|
|
} else {
|
|
let (input_result, needs_redraw) = self.composer.handle_key_event(key_event);
|
|
if needs_redraw {
|
|
self.request_redraw();
|
|
}
|
|
input_result
|
|
}
|
|
}
|
|
|
|
/// Update the status indicator text (only when the `StatusIndicatorView` is
|
|
/// active).
|
|
pub(crate) fn update_status_text(&mut self, text: String) {
|
|
if let Some(view) = &mut self.active_view {
|
|
match view.update_status_text(text) {
|
|
ConditionalUpdate::NeedsRedraw => {
|
|
self.request_redraw();
|
|
}
|
|
ConditionalUpdate::NoRedraw => {
|
|
// No redraw needed.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update the UI to reflect whether this `BottomPane` has input focus.
|
|
pub(crate) fn set_input_focus(&mut self, has_focus: bool) {
|
|
self.has_input_focus = has_focus;
|
|
self.composer.set_input_focus(has_focus);
|
|
}
|
|
|
|
pub fn set_task_running(&mut self, running: bool) {
|
|
self.is_task_running = running;
|
|
|
|
match (running, self.active_view.is_some()) {
|
|
(true, false) => {
|
|
// Show status indicator overlay.
|
|
let height = self.composer.calculate_required_height(&Rect::default());
|
|
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
|
self.app_event_tx.clone(),
|
|
height,
|
|
)));
|
|
self.request_redraw();
|
|
}
|
|
(false, true) => {
|
|
if let Some(mut view) = self.active_view.take() {
|
|
if view.should_hide_when_task_is_done() {
|
|
// Leave self.active_view as None.
|
|
self.request_redraw();
|
|
} else {
|
|
// Preserve the view.
|
|
self.active_view = Some(view);
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
// No change.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Update the *context-window remaining* indicator in the composer. This
|
|
/// is forwarded directly to the underlying `ChatComposer`.
|
|
pub(crate) fn set_token_usage(
|
|
&mut self,
|
|
token_usage: TokenUsage,
|
|
model_context_window: Option<u64>,
|
|
) {
|
|
self.composer
|
|
.set_token_usage(token_usage, model_context_window);
|
|
self.request_redraw();
|
|
}
|
|
|
|
/// Called when the agent requests user approval.
|
|
pub fn push_approval_request(&mut self, request: ApprovalRequest) {
|
|
let request = if let Some(view) = self.active_view.as_mut() {
|
|
match view.try_consume_approval_request(request) {
|
|
Some(request) => request,
|
|
None => {
|
|
self.request_redraw();
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
request
|
|
};
|
|
|
|
// Otherwise create a new approval modal overlay.
|
|
let modal = ApprovalModalView::new(request, self.app_event_tx.clone());
|
|
self.active_view = Some(Box::new(modal));
|
|
self.request_redraw()
|
|
}
|
|
|
|
/// Height (terminal rows) required by the current bottom pane.
|
|
pub fn calculate_required_height(&self, area: &Rect) -> u16 {
|
|
if let Some(view) = &self.active_view {
|
|
view.calculate_required_height(area)
|
|
} else {
|
|
self.composer.calculate_required_height(area)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn request_redraw(&self) {
|
|
self.app_event_tx.send(AppEvent::Redraw)
|
|
}
|
|
|
|
/// Returns true when the slash-command popup inside the composer is visible.
|
|
pub(crate) fn is_command_popup_visible(&self) -> bool {
|
|
self.active_view.is_none() && self.composer.is_command_popup_visible()
|
|
}
|
|
|
|
// --- History helpers ---
|
|
|
|
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
|
|
self.composer.set_history_metadata(log_id, entry_count);
|
|
}
|
|
|
|
pub(crate) fn on_history_entry_response(
|
|
&mut self,
|
|
log_id: u64,
|
|
offset: usize,
|
|
entry: Option<String>,
|
|
) {
|
|
let updated = self
|
|
.composer
|
|
.on_history_entry_response(log_id, offset, entry);
|
|
|
|
if updated {
|
|
self.request_redraw();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl WidgetRef for &BottomPane<'_> {
|
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
|
// Show BottomPaneView if present.
|
|
if let Some(ov) = &self.active_view {
|
|
ov.render(area, buf);
|
|
} else {
|
|
(&self.composer).render_ref(area, buf);
|
|
}
|
|
}
|
|
}
|