Merge 864181f738 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2025-09-28 10:34:24 -07:00
committed by GitHub
42 changed files with 414 additions and 1076 deletions
+8 -1
View File
@@ -692,12 +692,12 @@ dependencies = [
"codex-exec",
"codex-login",
"codex-mcp-server",
"codex-process-hardening",
"codex-protocol",
"codex-protocol-ts",
"codex-responses-api-proxy",
"codex-tui",
"ctor 0.5.0",
"libc",
"owo-colors",
"predicates",
"pretty_assertions",
@@ -949,6 +949,13 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-process-hardening"
version = "0.0.0"
dependencies = [
"libc",
]
[[package]]
name = "codex-protocol"
version = "0.0.0"
+4 -2
View File
@@ -16,6 +16,7 @@ members = [
"mcp-server",
"mcp-types",
"ollama",
"process-hardening",
"protocol",
"protocol-ts",
"rmcp-client",
@@ -49,10 +50,11 @@ codex-login = { path = "login" }
codex-mcp-client = { path = "mcp-client" }
codex-mcp-server = { path = "mcp-server" }
codex-ollama = { path = "ollama" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-rmcp-client = { path = "rmcp-client" }
codex-protocol-ts = { path = "protocol-ts" }
codex-responses-api-proxy = { path = "responses-api-proxy" }
codex-rmcp-client = { path = "rmcp-client" }
codex-tui = { path = "tui" }
codex-utils-readiness = { path = "utils/readiness" }
core_test_support = { path = "core/tests/common" }
@@ -83,8 +85,8 @@ dirs = "6"
dotenvy = "0.15.7"
env-flags = "0.1.1"
env_logger = "0.11.5"
eventsource-stream = "0.2.3"
escargot = "0.5"
eventsource-stream = "0.2.3"
futures = "0.3"
icu_decimal = "2.0.0"
icu_locale_core = "2.0.0"
+4 -4
View File
@@ -4,18 +4,18 @@ We provide Codex CLI as a standalone, native executable to ensure a zero-depende
## Installing Codex
Today, the easiest way to install Codex is via `npm`, though we plan to publish Codex to other package managers soon.
Today, the easiest way to install Codex is via `npm`:
```shell
npm i -g @openai/codex@native
npm i -g @openai/codex
codex
```
You can also download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases).
You can also install via Homebrew (`brew install codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases).
## What's new in the Rust CLI
While we are [working to close the gap between the TypeScript and Rust implementations of Codex CLI](https://github.com/openai/codex/issues/1262), note that the Rust CLI has a number of features that the TypeScript CLI does not!
The Rust implementation is now the maintained Codex CLI and serves as the default experience. It includes a number of features that the legacy TypeScript CLI never supported.
### Config
+1 -9
View File
@@ -25,6 +25,7 @@ codex-core = { workspace = true }
codex-exec = { workspace = true }
codex-login = { workspace = true }
codex-mcp-server = { workspace = true }
codex-process-hardening = { workspace = true }
codex-protocol = { workspace = true }
codex-protocol-ts = { workspace = true }
codex-responses-api-proxy = { workspace = true }
@@ -43,15 +44,6 @@ tokio = { workspace = true, features = [
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
libc = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
libc = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
libc = { workspace = true }
[dev-dependencies]
assert_cmd = { workspace = true }
predicates = { workspace = true }
+1 -9
View File
@@ -23,7 +23,6 @@ use std::path::PathBuf;
use supports_color::Stream;
mod mcp_cmd;
mod pre_main_hardening;
use crate::mcp_cmd::McpCli;
use crate::proto::ProtoCli;
@@ -213,14 +212,7 @@ fn pre_main_hardening() {
};
if secure_mode == "1" {
#[cfg(any(target_os = "linux", target_os = "android"))]
crate::pre_main_hardening::pre_main_hardening_linux();
#[cfg(target_os = "macos")]
crate::pre_main_hardening::pre_main_hardening_macos();
#[cfg(windows)]
crate::pre_main_hardening::pre_main_hardening_windows();
codex_process_hardening::pre_main_hardening();
}
// Always clear this env var so child processes don't inherit it.
+1 -1
View File
@@ -29,7 +29,7 @@ const PRESETS: &[ModelPreset] = &[
label: "gpt-5-codex medium",
description: "",
model: "gpt-5-codex",
effort: None,
effort: Some(ReasoningEffort::Medium),
},
ModelPreset {
id: "gpt-5-codex-high",
-89
View File
@@ -1,89 +0,0 @@
use anyhow::Context;
use serde::Deserialize;
use serde::Serialize;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
pub(crate) const INTERNAL_STORAGE_FILE: &str = "internal_storage.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InternalStorage {
#[serde(skip)]
storage_path: PathBuf,
#[serde(default = "default_gpt_5_codex_model_prompt_seen")]
pub gpt_5_codex_model_prompt_seen: bool,
}
const fn default_gpt_5_codex_model_prompt_seen() -> bool {
true
}
impl Default for InternalStorage {
fn default() -> Self {
Self {
storage_path: PathBuf::new(),
gpt_5_codex_model_prompt_seen: default_gpt_5_codex_model_prompt_seen(),
}
}
}
// TODO(jif) generalise all the file writers and build proper async channel inserters.
impl InternalStorage {
pub fn load(codex_home: &Path) -> Self {
let storage_path = codex_home.join(INTERNAL_STORAGE_FILE);
match std::fs::read_to_string(&storage_path) {
Ok(serialized) => match serde_json::from_str::<Self>(&serialized) {
Ok(mut storage) => {
storage.storage_path = storage_path;
storage
}
Err(error) => {
tracing::warn!("failed to parse internal storage: {error:?}");
Self::empty(storage_path)
}
},
Err(error) => {
if error.kind() == ErrorKind::NotFound {
tracing::debug!(
"internal storage not found at {}; initializing defaults",
storage_path.display()
);
} else {
tracing::warn!("failed to read internal storage: {error:?}");
}
Self::empty(storage_path)
}
}
}
fn empty(storage_path: PathBuf) -> Self {
Self {
storage_path,
..Default::default()
}
}
pub async fn persist(&self) -> anyhow::Result<()> {
let serialized = serde_json::to_string_pretty(self)?;
if let Some(parent) = self.storage_path.parent() {
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!(
"failed to create internal storage directory at {}",
parent.display()
)
})?;
}
tokio::fs::write(&self.storage_path, serialized)
.await
.with_context(|| {
format!(
"failed to persist internal storage at {}",
self.storage_path.display()
)
})
}
}
-1
View File
@@ -29,7 +29,6 @@ mod exec_command;
pub mod exec_env;
mod flags;
pub mod git_info;
pub mod internal_storage;
pub mod landlock;
mod mcp_connection_manager;
mod mcp_tool_call;
+21
View File
@@ -0,0 +1,21 @@
[package]
edition = "2024"
name = "codex-process-hardening"
version = { workspace = true }
[lib]
name = "codex_process_hardening"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
[target.'cfg(target_os = "linux")'.dependencies]
libc = { workspace = true }
[target.'cfg(target_os = "android")'.dependencies]
libc = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
libc = { workspace = true }
@@ -1,3 +1,19 @@
/// This is designed to be called pre-main() (using `#[ctor::ctor]`) to perform
/// various process hardening steps, such as
/// - disabling core dumps
/// - disabling ptrace attach on Linux and macOS.
/// - removing dangerous environment variables such as LD_PRELOAD and DYLD_*
pub fn pre_main_hardening() {
#[cfg(any(target_os = "linux", target_os = "android"))]
pre_main_hardening_linux();
#[cfg(target_os = "macos")]
pre_main_hardening_macos();
#[cfg(windows)]
pre_main_hardening_windows();
}
#[cfg(any(target_os = "linux", target_os = "android"))]
const PRCTL_FAILED_EXIT_CODE: i32 = 5;
+1
View File
@@ -90,6 +90,7 @@ impl AsciiAnimation {
true
}
#[allow(dead_code)]
pub(crate) fn request_frame(&self) {
self.request_frame.schedule_frame();
}
+29 -210
View File
@@ -1,3 +1,4 @@
use codex_core::protocol::TokenUsageInfo;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
@@ -18,14 +19,8 @@ use super::chat_composer_history::ChatComposerHistory;
use super::command_popup::CommandItem;
use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup;
use super::footer::FooterMode;
use super::footer::FooterProps;
use super::footer::esc_hint_mode;
use super::footer::footer_height;
use super::footer::prompt_mode;
use super::footer::render_footer;
use super::footer::reset_mode_after_activity;
use super::footer::toggle_shortcut_mode;
use super::paste_burst::CharDecision;
use super::paste_burst::PasteBurst;
use crate::bottom_pane::paste_burst::FlushResult;
@@ -79,6 +74,7 @@ pub(crate) struct ChatComposer {
dismissed_file_popup_token: Option<String>,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
attached_images: Vec<AttachedImage>,
placeholder_text: String,
@@ -88,7 +84,6 @@ pub(crate) struct ChatComposer {
// When true, disables paste-burst logic and inserts characters immediately.
disable_paste_burst: bool,
custom_prompts: Vec<CustomPrompt>,
footer_mode: FooterMode,
}
/// Popup state – at most one can be visible at any time.
@@ -98,7 +93,7 @@ enum ActivePopup {
File(FileSearchPopup),
}
const FOOTER_SPACING_HEIGHT: u16 = 1;
const FOOTER_HINT_HEIGHT: u16 = 1;
impl ChatComposer {
pub fn new(
@@ -122,6 +117,7 @@ impl ChatComposer {
dismissed_file_popup_token: None,
current_file_query: None,
pending_pastes: Vec::new(),
token_usage_info: None,
has_focus: has_input_focus,
attached_images: Vec::new(),
placeholder_text,
@@ -129,7 +125,6 @@ impl ChatComposer {
paste_burst: PasteBurst::default(),
disable_paste_burst: false,
custom_prompts: Vec::new(),
footer_mode: FooterMode::ShortcutPrompt,
};
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
@@ -137,41 +132,26 @@ impl ChatComposer {
}
pub fn desired_height(&self, width: u16) -> u16 {
let footer_props = self.footer_props();
let footer_hint_height = footer_height(footer_props);
let footer_spacing = if footer_hint_height > 0 {
FOOTER_SPACING_HEIGHT
} else {
0
};
let footer_total_height = footer_hint_height + footer_spacing;
self.textarea
.desired_height(width.saturating_sub(LIVE_PREFIX_COLS))
+ 2
+ match &self.active_popup {
ActivePopup::None => footer_total_height,
ActivePopup::None => FOOTER_HINT_HEIGHT,
ActivePopup::Command(c) => c.calculate_required_height(width),
ActivePopup::File(c) => c.calculate_required_height(),
}
}
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let footer_props = self.footer_props();
let footer_hint_height = footer_height(footer_props);
let footer_spacing = if footer_hint_height > 0 {
FOOTER_SPACING_HEIGHT
} else {
0
};
let footer_total_height = footer_hint_height + footer_spacing;
let popup_constraint = match &self.active_popup {
ActivePopup::Command(popup) => {
Constraint::Max(popup.calculate_required_height(area.width))
}
ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()),
ActivePopup::None => Constraint::Max(footer_total_height),
ActivePopup::None => Constraint::Max(FOOTER_HINT_HEIGHT),
};
let mut area = area;
// Leave an empty row at the top, unless there isn't room.
if area.height > 1 {
area.height -= 1;
area.y += 1;
@@ -195,6 +175,13 @@ impl ChatComposer {
self.textarea.is_empty()
}
/// Update the cached *context-left* percentage and refresh the placeholder
/// text. The UI relies on the placeholder to convey the remaining
/// context when the composer is empty.
pub(crate) fn set_token_usage(&mut self, token_info: Option<TokenUsageInfo>) {
self.token_usage_info = token_info;
}
/// Record the history metadata advertised by `SessionConfiguredEvent` so
/// that the composer can navigate cross-session history.
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
@@ -332,11 +319,6 @@ impl ChatComposer {
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
self.ctrl_c_quit_hint = show;
if show {
self.footer_mode = prompt_mode();
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
self.set_has_focus(has_focus);
}
@@ -372,18 +354,6 @@ impl ChatComposer {
/// Handle key event when the slash-command popup is visible.
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
let ActivePopup::Command(popup) = &mut self.active_popup else {
unreachable!();
};
@@ -508,18 +478,6 @@ impl ChatComposer {
/// Handle key events when file search popup is visible.
fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
let ActivePopup::File(popup) = &mut self.active_popup else {
unreachable!();
};
@@ -771,18 +729,6 @@ impl ChatComposer {
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
if self.handle_shortcut_overlay_key(&key_event) {
return (InputResult::None, true);
}
if key_event.code == KeyCode::Esc {
let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
if next_mode != self.footer_mode {
self.footer_mode = next_mode;
return (InputResult::None, true);
}
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
match key_event {
KeyEvent {
code: KeyCode::Char('d'),
@@ -926,10 +872,6 @@ impl ChatComposer {
let now = Instant::now();
self.handle_paste_burst_flush(now);
if !matches!(input.code, KeyCode::Esc) {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
// If we're capturing a burst and receive Enter, accumulate it instead of inserting.
if matches!(input.code, KeyCode::Enter)
&& self.paste_burst.is_active()
@@ -1203,39 +1145,6 @@ impl ChatComposer {
false
}
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
if key_event.kind == KeyEventKind::Press
&& key_event.modifiers.is_empty()
&& matches!(key_event.code, KeyCode::Char('?'))
{
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
let changed = next != self.footer_mode;
self.footer_mode = next;
changed
} else {
false
}
}
fn footer_props(&self) -> FooterProps {
FooterProps {
mode: self.footer_mode(),
esc_backtrack_hint: self.esc_backtrack_hint,
use_shift_enter_hint: self.use_shift_enter_hint,
is_task_running: self.is_task_running,
}
}
fn footer_mode(&self) -> FooterMode {
if matches!(self.footer_mode, FooterMode::EscHint) {
FooterMode::EscHint
} else if self.ctrl_c_quit_hint {
FooterMode::CtrlCReminder
} else {
self.footer_mode
}
}
/// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
@@ -1319,18 +1228,10 @@ impl ChatComposer {
pub fn set_task_running(&mut self, running: bool) {
self.is_task_running = running;
if running {
self.footer_mode = prompt_mode();
}
}
pub(crate) fn set_esc_backtrack_hint(&mut self, show: bool) {
self.esc_backtrack_hint = show;
if show {
self.footer_mode = esc_hint_mode(self.footer_mode, self.is_task_running);
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
}
}
@@ -1351,26 +1252,20 @@ impl WidgetRef for ChatComposer {
popup.render_ref(popup_rect, buf);
}
ActivePopup::None => {
let footer_hint_height = footer_height(self.footer_props());
let footer_spacing = if footer_hint_height > 0 {
FOOTER_SPACING_HEIGHT
} else {
0
};
let hint_rect = if footer_spacing > 0 && footer_hint_height > 0 {
let [_, hint_rect] = Layout::vertical([
Constraint::Length(footer_spacing),
Constraint::Length(footer_hint_height),
])
.areas(popup_rect);
hint_rect
} else {
popup_rect
};
let mut footer_rect = hint_rect;
footer_rect.x = footer_rect.x.saturating_add(2);
footer_rect.width = footer_rect.width.saturating_sub(2);
render_footer(footer_rect, buf, self.footer_props());
let mut hint_rect = popup_rect;
hint_rect.x += 2;
hint_rect.width = hint_rect.width.saturating_sub(2);
render_footer(
hint_rect,
buf,
FooterProps {
ctrl_c_quit_hint: self.ctrl_c_quit_hint,
is_task_running: self.is_task_running,
esc_backtrack_hint: self.esc_backtrack_hint,
use_shift_enter_hint: self.use_shift_enter_hint,
token_usage_info: self.token_usage_info.as_ref(),
},
);
}
}
let style = user_message_style(terminal_palette::default_bg());
@@ -1409,7 +1304,6 @@ mod tests {
use crate::bottom_pane::InputResult;
use crate::bottom_pane::chat_composer::AttachedImage;
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
use crate::bottom_pane::footer::footer_height;
use crate::bottom_pane::textarea::TextArea;
use tokio::sync::mpsc::unbounded_channel;
@@ -1440,7 +1334,7 @@ mod tests {
let mut hint_row: Option<(u16, String)> = None;
for y in 0..area.height {
let row = row_to_string(y);
if row.contains("? for shortcuts") {
if row.contains(" send") {
hint_row = Some((y, row));
break;
}
@@ -1467,81 +1361,6 @@ mod tests {
);
}
fn snapshot_composer_state<F>(name: &str, enhanced_keys_supported: bool, setup: F)
where
F: FnOnce(&mut ChatComposer),
{
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let width = 100;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
enhanced_keys_supported,
"Ask Codex to do anything".to_string(),
false,
);
setup(&mut composer);
let footer_lines = footer_height(composer.footer_props());
let height = footer_lines + 8;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.unwrap();
insta::assert_snapshot!(name, terminal.backend());
}
#[test]
fn footer_mode_snapshots() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
snapshot_composer_state("footer_mode_shortcut_overlay", true, |composer| {
composer.set_esc_backtrack_hint(true);
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
});
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
composer.set_task_running(true);
composer.set_ctrl_c_quit_hint(true, true);
});
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
composer.set_ctrl_c_quit_hint(true, true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_esc_hint_from_overlay", true, |composer| {
let _ =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state("footer_mode_esc_hint_backtrack", true, |composer| {
composer.set_esc_backtrack_hint(true);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
});
snapshot_composer_state(
"footer_mode_overlay_then_external_esc_hint",
true,
|composer| {
let _ = composer
.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE));
composer.set_esc_backtrack_hint(true);
},
);
}
#[test]
fn test_current_at_token_basic_cases() {
let test_cases = vec![
+209 -266
View File
@@ -1,190 +1,149 @@
use codex_core::protocol::TokenUsageInfo;
use codex_protocol::num_format::format_si_suffix;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::WidgetRef;
use crate::key_hint;
#[derive(Clone, Copy, Debug)]
pub(crate) struct FooterProps {
pub(crate) mode: FooterMode,
pub(crate) struct FooterProps<'a> {
pub(crate) ctrl_c_quit_hint: bool,
pub(crate) is_task_running: bool,
pub(crate) esc_backtrack_hint: bool,
pub(crate) use_shift_enter_hint: bool,
pub(crate) is_task_running: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum FooterMode {
CtrlCReminder,
ShortcutPrompt,
ShortcutOverlay,
EscHint,
}
pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode {
if ctrl_c_hint {
return current;
}
match current {
FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutPrompt,
_ => FooterMode::ShortcutOverlay,
}
}
pub(crate) fn esc_hint_mode(current: FooterMode, is_task_running: bool) -> FooterMode {
if is_task_running {
current
} else {
FooterMode::EscHint
}
}
pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
match current {
FooterMode::EscHint | FooterMode::ShortcutOverlay => FooterMode::ShortcutPrompt,
other => other,
}
}
pub(crate) fn prompt_mode() -> FooterMode {
FooterMode::ShortcutPrompt
}
pub(crate) fn footer_height(props: FooterProps) -> u16 {
footer_lines(props).len() as u16
}
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps) {
let lines = footer_lines(props);
for (idx, line) in lines.into_iter().enumerate() {
let y = area.y + idx as u16;
if y >= area.y + area.height {
break;
}
let row = Rect::new(area.x, y, area.width, 1);
line.render_ref(row, buf);
}
}
fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
match props.mode {
FooterMode::CtrlCReminder => {
vec![ctrl_c_reminder_line(CtrlCReminderState {
is_task_running: props.is_task_running,
})]
}
FooterMode::ShortcutPrompt => vec![Line::from(vec!["? for shortcuts".dim()])],
FooterMode::ShortcutOverlay => shortcut_overlay_lines(ShortcutsState {
use_shift_enter_hint: props.use_shift_enter_hint,
esc_backtrack_hint: props.esc_backtrack_hint,
is_task_running: props.is_task_running,
}),
FooterMode::EscHint => {
vec![esc_hint_line(ShortcutsState {
use_shift_enter_hint: props.use_shift_enter_hint,
esc_backtrack_hint: props.esc_backtrack_hint,
is_task_running: props.is_task_running,
})]
}
}
pub(crate) token_usage_info: Option<&'a TokenUsageInfo>,
}
#[derive(Clone, Copy, Debug)]
struct CtrlCReminderState {
is_task_running: bool,
pub(crate) is_task_running: bool,
}
#[derive(Clone, Copy, Debug)]
struct ShortcutsState {
use_shift_enter_hint: bool,
esc_backtrack_hint: bool,
is_task_running: bool,
pub(crate) use_shift_enter_hint: bool,
pub(crate) esc_backtrack_hint: bool,
}
fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
let action = if state.is_task_running {
"interrupt"
#[derive(Clone, Copy, Debug)]
enum FooterContent {
Shortcuts(ShortcutsState),
CtrlCReminder(CtrlCReminderState),
}
pub(crate) fn render_footer(area: Rect, buf: &mut Buffer, props: FooterProps<'_>) {
let content = if props.ctrl_c_quit_hint {
FooterContent::CtrlCReminder(CtrlCReminderState {
is_task_running: props.is_task_running,
})
} else {
"quit"
FooterContent::Shortcuts(ShortcutsState {
use_shift_enter_hint: props.use_shift_enter_hint,
esc_backtrack_hint: props.esc_backtrack_hint,
})
};
Line::from(vec![
Span::from(format!(" ctrl + c again to {action}")).dim(),
])
let mut spans = footer_spans(content);
if let Some(token_usage_info) = props.token_usage_info {
append_token_usage_spans(&mut spans, token_usage_info);
}
let spans = spans
.into_iter()
.map(|span| span.patch_style(Style::default().dim()))
.collect::<Vec<_>>();
Line::from(spans).render_ref(area, buf);
}
fn esc_hint_line(state: ShortcutsState) -> Line<'static> {
let text = if state.esc_backtrack_hint {
" esc again to edit previous message"
} else {
" esc esc to edit previous message"
};
Line::from(vec![Span::from(text).dim()])
fn footer_spans(content: FooterContent) -> Vec<Span<'static>> {
match content {
FooterContent::Shortcuts(state) => shortcuts_spans(state),
FooterContent::CtrlCReminder(state) => ctrl_c_reminder_spans(state),
}
}
fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
let mut rendered = Vec::new();
fn append_token_usage_spans(spans: &mut Vec<Span<'static>>, token_usage_info: &TokenUsageInfo) {
let token_usage = &token_usage_info.total_token_usage;
spans.push(" ".into());
spans.push(
Span::from(format!(
"{} tokens used",
format_si_suffix(token_usage.blended_total())
))
.style(Style::default().add_modifier(Modifier::DIM)),
);
let last_token_usage = &token_usage_info.last_token_usage;
if let Some(context_window) = token_usage_info.model_context_window {
let percent_remaining: u8 = if context_window > 0 {
last_token_usage.percent_of_context_window_remaining(context_window)
} else {
100
};
let context_style = if percent_remaining < 20 {
Style::default().fg(Color::Yellow)
} else {
Style::default().add_modifier(Modifier::DIM)
};
spans.push(" ".into());
spans.push(Span::styled(
format!("{percent_remaining}% context left"),
context_style,
));
}
}
fn shortcuts_spans(state: ShortcutsState) -> Vec<Span<'static>> {
let mut spans = Vec::new();
for descriptor in SHORTCUTS {
if let Some(text) = descriptor.overlay_entry(state) {
rendered.push(text);
if let Some(segment) = descriptor.footer_segment(state) {
if !segment.prefix.is_empty() {
spans.push(segment.prefix.into());
}
spans.push(segment.binding.span());
spans.push(segment.label.into());
}
}
build_columns(rendered)
spans
}
fn build_columns(entries: Vec<String>) -> Vec<Line<'static>> {
if entries.is_empty() {
return Vec::new();
}
const COLUMNS: usize = 3;
const MAX_PADDED_WIDTHS: [usize; COLUMNS - 1] = [24, 28];
let rows = entries.len().div_ceil(COLUMNS);
let mut column_widths = [0usize; COLUMNS];
for (idx, entry) in entries.iter().enumerate() {
let column = idx % COLUMNS;
column_widths[column] = column_widths[column].max(entry.len());
}
let mut lines = Vec::new();
for row in 0..rows {
let mut line = String::from(" ");
for col in 0..COLUMNS {
let idx = row * COLUMNS + col;
if idx >= entries.len() {
continue;
}
let entry = &entries[idx];
if col < COLUMNS - 1 {
let max_width = MAX_PADDED_WIDTHS[col];
let target_width = column_widths[col].min(max_width);
let pad_width = target_width + 2;
line.push_str(&format!("{entry:<pad_width$}"));
} else {
if col != 0 {
line.push_str(" ");
}
line.push_str(entry);
}
}
lines.push(Line::from(vec![Span::from(line).dim()]));
}
lines
fn ctrl_c_reminder_spans(state: CtrlCReminderState) -> Vec<Span<'static>> {
let followup = if state.is_task_running {
" to interrupt"
} else {
" to quit"
};
vec![
" ".into(),
key_hint::ctrl('C'),
" again".into(),
followup.into(),
]
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug)]
struct FooterSegment {
prefix: &'static str,
binding: ShortcutBinding,
label: &'static str,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
enum ShortcutId {
Commands,
FilePaths,
Send,
InsertNewline,
PasteImage,
Quit,
ShowTranscript,
ToggleOverlay,
Quit,
EditPrevious,
}
@@ -192,13 +151,30 @@ enum ShortcutId {
struct ShortcutBinding {
code: KeyCode,
modifiers: KeyModifiers,
overlay_text: &'static str,
display: ShortcutDisplay,
condition: DisplayCondition,
}
impl ShortcutBinding {
fn matches(&self, state: ShortcutsState) -> bool {
self.condition.matches(state)
fn span(&self) -> Span<'static> {
self.display.into_span()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ShortcutDisplay {
Plain(&'static str),
Ctrl(char),
Shift(char),
}
impl ShortcutDisplay {
fn into_span(self) -> Span<'static> {
match self {
ShortcutDisplay::Plain(text) => key_hint::plain(text),
ShortcutDisplay::Ctrl(ch) => key_hint::ctrl(ch),
ShortcutDisplay::Shift(ch) => key_hint::shift(ch),
}
}
}
@@ -222,66 +198,49 @@ impl DisplayCondition {
struct ShortcutDescriptor {
id: ShortcutId,
bindings: &'static [ShortcutBinding],
prefix: &'static str,
label: &'static str,
footer_label: &'static str,
footer_prefix: &'static str,
}
impl ShortcutDescriptor {
fn binding_for(&self, state: ShortcutsState) -> Option<&'static ShortcutBinding> {
self.bindings.iter().find(|binding| binding.matches(state))
fn binding_for(&self, state: ShortcutsState) -> Option<ShortcutBinding> {
self.bindings
.iter()
.find(|binding| binding.condition.matches(state))
.copied()
}
fn overlay_entry(&self, state: ShortcutsState) -> Option<String> {
fn should_show(&self, state: ShortcutsState) -> bool {
match self.id {
ShortcutId::EditPrevious => state.esc_backtrack_hint,
_ => true,
}
}
fn footer_segment(&self, state: ShortcutsState) -> Option<FooterSegment> {
if !self.should_show(state) {
return None;
}
let binding = self.binding_for(state)?;
let label = match self.id {
ShortcutId::Quit => {
if state.is_task_running {
" to interrupt"
} else {
self.label
}
}
ShortcutId::EditPrevious => {
if state.esc_backtrack_hint {
" again to edit previous message"
} else {
" esc to edit previous message"
}
}
_ => self.label,
};
let text = match self.id {
ShortcutId::Quit if state.is_task_running => {
format!("{}{} to interrupt", self.prefix, binding.overlay_text)
}
_ => format!("{}{}{}", self.prefix, binding.overlay_text, label),
};
Some(text)
Some(FooterSegment {
prefix: self.footer_prefix,
binding,
label: self.footer_label,
})
}
}
const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutDescriptor {
id: ShortcutId::Commands,
id: ShortcutId::Send,
bindings: &[ShortcutBinding {
code: KeyCode::Char('/'),
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
overlay_text: "/",
display: ShortcutDisplay::Plain("⏎"),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " for commands",
},
ShortcutDescriptor {
id: ShortcutId::FilePaths,
bindings: &[ShortcutBinding {
code: KeyCode::Char('@'),
modifiers: KeyModifiers::NONE,
overlay_text: "@",
condition: DisplayCondition::Always,
}],
prefix: "",
label: " for file paths",
footer_label: " send ",
footer_prefix: "",
},
ShortcutDescriptor {
id: ShortcutId::InsertNewline,
@@ -289,154 +248,138 @@ const SHORTCUTS: &[ShortcutDescriptor] = &[
ShortcutBinding {
code: KeyCode::Enter,
modifiers: KeyModifiers::SHIFT,
overlay_text: "shift + enter",
display: ShortcutDisplay::Shift('⏎'),
condition: DisplayCondition::WhenShiftEnterHint,
},
ShortcutBinding {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + j",
display: ShortcutDisplay::Ctrl('J'),
condition: DisplayCondition::WhenNotShiftEnterHint,
},
],
prefix: "",
label: " for newline",
},
ShortcutDescriptor {
id: ShortcutId::PasteImage,
bindings: &[ShortcutBinding {
code: KeyCode::Char('v'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + v",
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to paste images",
},
ShortcutDescriptor {
id: ShortcutId::Quit,
bindings: &[ShortcutBinding {
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + c",
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to exit",
footer_label: " newline ",
footer_prefix: "",
},
ShortcutDescriptor {
id: ShortcutId::ShowTranscript,
bindings: &[ShortcutBinding {
code: KeyCode::Char('t'),
modifiers: KeyModifiers::CONTROL,
overlay_text: "ctrl + t",
display: ShortcutDisplay::Ctrl('T'),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to view transcript",
footer_label: " transcript ",
footer_prefix: "",
},
ShortcutDescriptor {
id: ShortcutId::ToggleOverlay,
id: ShortcutId::Quit,
bindings: &[ShortcutBinding {
code: KeyCode::Char('?'),
modifiers: KeyModifiers::NONE,
overlay_text: "?",
code: KeyCode::Char('c'),
modifiers: KeyModifiers::CONTROL,
display: ShortcutDisplay::Ctrl('C'),
condition: DisplayCondition::Always,
}],
prefix: "",
label: " to hide shortcuts",
footer_label: " quit",
footer_prefix: "",
},
ShortcutDescriptor {
id: ShortcutId::EditPrevious,
bindings: &[ShortcutBinding {
code: KeyCode::Esc,
modifiers: KeyModifiers::NONE,
overlay_text: "esc",
display: ShortcutDisplay::Plain("Esc"),
condition: DisplayCondition::Always,
}],
prefix: "",
label: "",
footer_label: " edit prev",
footer_prefix: " ",
},
];
#[cfg(test)]
mod tests {
use super::*;
use codex_core::protocol::TokenUsage;
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn snapshot_footer(name: &str, props: FooterProps) {
let height = footer_height(props).max(1);
let mut terminal = Terminal::new(TestBackend::new(80, height)).unwrap();
fn snapshot_footer(name: &str, props: FooterProps<'_>) {
let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, f.area().width, height);
let area = Rect::new(0, 0, f.area().width, 1);
render_footer(area, f.buffer_mut(), props);
})
.unwrap();
assert_snapshot!(name, terminal.backend());
}
fn token_usage(total_tokens: u64, last_tokens: u64, context_window: u64) -> TokenUsageInfo {
let usage = TokenUsage {
input_tokens: total_tokens,
cached_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens,
};
let last = TokenUsage {
input_tokens: last_tokens,
cached_input_tokens: 0,
output_tokens: 0,
reasoning_output_tokens: 0,
total_tokens: last_tokens,
};
TokenUsageInfo {
total_token_usage: usage,
last_token_usage: last,
model_context_window: Some(context_window),
}
}
#[test]
fn footer_snapshots() {
snapshot_footer(
"footer_shortcuts_default",
FooterProps {
mode: FooterMode::ShortcutPrompt,
ctrl_c_quit_hint: false,
is_task_running: false,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
token_usage_info: None,
},
);
snapshot_footer(
"footer_shortcuts_shift_and_esc",
FooterProps {
mode: FooterMode::ShortcutOverlay,
ctrl_c_quit_hint: false,
is_task_running: false,
esc_backtrack_hint: true,
use_shift_enter_hint: true,
is_task_running: false,
token_usage_info: Some(&token_usage(4_200, 900, 8_000)),
},
);
snapshot_footer(
"footer_ctrl_c_quit_idle",
FooterProps {
mode: FooterMode::CtrlCReminder,
ctrl_c_quit_hint: true,
is_task_running: false,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
token_usage_info: None,
},
);
snapshot_footer(
"footer_ctrl_c_quit_running",
FooterProps {
mode: FooterMode::CtrlCReminder,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
ctrl_c_quit_hint: true,
is_task_running: true,
},
);
snapshot_footer(
"footer_esc_hint_idle",
FooterProps {
mode: FooterMode::EscHint,
esc_backtrack_hint: false,
use_shift_enter_hint: false,
is_task_running: false,
},
);
snapshot_footer(
"footer_esc_hint_primed",
FooterProps {
mode: FooterMode::EscHint,
esc_backtrack_hint: true,
use_shift_enter_hint: false,
is_task_running: false,
token_usage_info: None,
},
);
}
+12
View File
@@ -4,6 +4,7 @@ use std::path::PathBuf;
use crate::app_event_sender::AppEventSender;
use crate::tui::FrameRequester;
use bottom_pane_view::BottomPaneView;
use codex_core::protocol::TokenUsageInfo;
use codex_file_search::FileMatch;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -102,6 +103,10 @@ impl BottomPane {
}
}
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
self.status.as_ref()
}
fn active_view(&self) -> Option<&dyn BottomPaneView> {
self.view_stack.last().map(std::convert::AsRef::as_ref)
}
@@ -372,6 +377,13 @@ impl BottomPane {
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
}
/// 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_info: Option<TokenUsageInfo>) {
self.composer.set_token_usage(token_info);
self.request_redraw();
}
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
self.push_view(view);
}
@@ -11,4 +11,4 @@ expression: terminal.backend()
" "
" "
" "
" ? for shortcuts "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
@@ -11,4 +11,4 @@ expression: terminal.backend()
" "
" "
" "
" ? for shortcuts "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
@@ -1,13 +0,0 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" ctrl + c again to interrupt "
@@ -1,13 +0,0 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" ctrl + c again to quit "
@@ -1,13 +0,0 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" esc esc to edit previous message "
@@ -1,13 +0,0 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" esc again to edit previous message "
@@ -1,13 +0,0 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" esc esc to edit previous message "
@@ -1,13 +0,0 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" esc again to edit previous message "
@@ -1,15 +0,0 @@
---
source: tui/src/bottom_pane/chat_composer.rs
expression: terminal.backend()
---
" "
"› Ask Codex to do anything "
" "
" "
" "
" "
" "
" "
" / for commands @ for file paths shift + enter for newline "
" ctrl + v to paste images ctrl + c to exit ctrl + t to view transcript "
" ? to hide shortcuts esc again to edit previous message "
@@ -11,4 +11,4 @@ expression: terminal.backend()
" "
" "
" "
" ? for shortcuts "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
@@ -11,4 +11,4 @@ expression: terminal.backend()
" "
" "
" "
" ? for shortcuts "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
@@ -11,4 +11,4 @@ expression: terminal.backend()
" "
" "
" "
" ? for shortcuts "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
@@ -2,4 +2,6 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" ctrl + c again to quit "
" ⌃C again to quit "
" "
" "
@@ -2,4 +2,6 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" ctrl + c again to interrupt "
" ⌃C again to interrupt "
" "
" "
@@ -1,5 +0,0 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" esc esc to edit previous message "
@@ -1,5 +0,0 @@
---
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" esc again to edit previous message "
@@ -2,4 +2,6 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
"? for shortcuts "
"⏎ send ⌃J newline ⌃T transcript ⌃C quit "
" "
" "
@@ -2,6 +2,6 @@
source: tui/src/bottom_pane/footer.rs
expression: terminal.backend()
---
" / for commands @ for file paths shift + enter for ne"
" ctrl + v to paste images ctrl + c to exit ctrl + t to view tra"
" ? to hide shortcuts esc again to edit previous message "
"⏎ send ⇧⏎ newline ⌃T transcript ⌃C quit Esc edit prev 4.20K tokens use"
" "
" "
+17
View File
@@ -254,6 +254,8 @@ pub(crate) struct ChatWidget {
// List of ghost commits corresponding to each turn.
ghost_snapshots: Vec<GhostCommit>,
ghost_snapshots_disabled: bool,
// Whether to add a final message separator after the last message
needs_final_message_separator: bool,
}
struct UserMessage {
@@ -393,6 +395,7 @@ impl ChatWidget {
pub(crate) fn set_token_info(&mut self, info: Option<TokenUsageInfo>) {
if info.is_some() {
self.bottom_pane.set_token_usage(info.clone());
self.token_info = info;
}
}
@@ -648,6 +651,14 @@ impl ChatWidget {
self.flush_active_cell();
if self.stream_controller.is_none() {
if self.needs_final_message_separator {
let elapsed_seconds = self
.bottom_pane
.status_widget()
.map(super::status_indicator_widget::StatusIndicatorWidget::elapsed_seconds);
self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds));
self.needs_final_message_separator = false;
}
self.stream_controller = Some(StreamController::new(self.config.clone()));
}
if let Some(controller) = self.stream_controller.as_mut()
@@ -901,6 +912,7 @@ impl ChatWidget {
is_review_mode: false,
ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: true,
needs_final_message_separator: false,
}
}
@@ -962,6 +974,7 @@ impl ChatWidget {
is_review_mode: false,
ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: true,
needs_final_message_separator: false,
}
}
@@ -1188,6 +1201,7 @@ impl ChatWidget {
fn flush_active_cell(&mut self) {
if let Some(active) = self.active_cell.take() {
self.needs_final_message_separator = true;
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
}
}
@@ -1200,6 +1214,7 @@ impl ChatWidget {
if !cell.display_lines(u16::MAX).is_empty() {
// Only break exec grouping if the cell renders visible lines.
self.flush_active_cell();
self.needs_final_message_separator = true;
}
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
}
@@ -1241,6 +1256,7 @@ impl ChatWidget {
if !text.is_empty() {
self.add_to_history(history_cell::new_user_prompt(text));
}
self.needs_final_message_separator = false;
}
fn capture_ghost_snapshot(&mut self) {
@@ -1959,6 +1975,7 @@ impl ChatWidget {
pub(crate) fn clear_token_usage(&mut self) {
self.token_info = None;
self.bottom_pane.set_token_usage(None);
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 1152
expression: "lines[start_idx..].join(\"\\n\")"
---
• I need to check the codex-rs repository to explain why the project's binaries
@@ -9,6 +10,8 @@ expression: "lines[start_idx..].join(\"\\n\")"
is set up. I should look into the Cargo.toml file to confirm features and
profiles without needing to edit any code. Let's get started on this!
─ Worked for 0s ────────────────────────────────────────────────────────────────
• I’m going to scan the workspace and Cargo manifests to see build profiles and
dependencies that impact binary size. Then I’ll summarize the main causes.
@@ -110,6 +113,8 @@ expression: "lines[start_idx..].join(\"\\n\")"
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
reducing size, but I want to stay focused on answering the user's question.
─ Worked for 0s ────────────────────────────────────────────────────────────────
• Here’s what’s driving size in this workspace’s binaries.
Main Causes
@@ -14,5 +14,4 @@ expression: term.backend().vt100().screen().contents()
› Summarize recent commits
? for shortcuts
⏎ send ⌃J newline ⌃T transcript ⌃C quit
@@ -8,6 +8,5 @@ expression: terminal.backend()
" "
"› Ask Codex to do anything "
" "
" "
" ? for shortcuts "
" ⏎ send ⌃J newline ⌃T transcript ⌃C quit "
" "
+1
View File
@@ -340,6 +340,7 @@ fn make_chatwidget_manual() -> (
is_review_mode: false,
ghost_snapshots: Vec::new(),
ghost_snapshots_disabled: false,
needs_final_message_separator: false,
};
(widget, rx, op_rx)
}
+34
View File
@@ -1090,6 +1090,40 @@ pub(crate) fn new_reasoning_summary_block(
Box::new(new_reasoning_block(full_reasoning_buffer, config))
}
#[derive(Debug)]
pub struct FinalMessageSeparator {
elapsed_seconds: Option<u64>,
}
impl FinalMessageSeparator {
pub(crate) fn new(elapsed_seconds: Option<u64>) -> Self {
Self { elapsed_seconds }
}
}
impl HistoryCell for FinalMessageSeparator {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let elapsed_seconds = self
.elapsed_seconds
.map(super::status_indicator_widget::fmt_elapsed_compact);
if let Some(elapsed_seconds) = elapsed_seconds {
let worked_for = format!("─ Worked for {elapsed_seconds} ─");
let worked_for_width = worked_for.width();
vec![
Line::from_iter([
worked_for,
"─".repeat((width as usize).saturating_sub(worked_for_width)),
])
.dim(),
]
} else {
vec![Line::from_iter(["─".repeat(width as usize).dim()])]
}
}
fn transcript_lines(&self) -> Vec<Line<'static>> {
vec![]
}
}
fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
let args_str = invocation
.arguments
+26
View File
@@ -10,6 +10,20 @@ const ALT_PREFIX: &str = "⌥";
#[cfg(all(not(test), not(target_os = "macos")))]
const ALT_PREFIX: &str = "Alt+";
#[cfg(test)]
const CTRL_PREFIX: &str = "⌃";
#[cfg(all(not(test), target_os = "macos"))]
const CTRL_PREFIX: &str = "⌃";
#[cfg(all(not(test), not(target_os = "macos")))]
const CTRL_PREFIX: &str = "Ctrl+";
#[cfg(test)]
const SHIFT_PREFIX: &str = "⇧";
#[cfg(all(not(test), target_os = "macos"))]
const SHIFT_PREFIX: &str = "⇧";
#[cfg(all(not(test), not(target_os = "macos")))]
const SHIFT_PREFIX: &str = "Shift+";
fn key_hint_style() -> Style {
Style::default().bold()
}
@@ -18,6 +32,18 @@ fn modifier_span(prefix: &str, key: impl Display) -> Span<'static> {
Span::styled(format!("{prefix}{key}"), key_hint_style())
}
pub(crate) fn ctrl(key: impl Display) -> Span<'static> {
modifier_span(CTRL_PREFIX, key)
}
pub(crate) fn alt(key: impl Display) -> Span<'static> {
modifier_span(ALT_PREFIX, key)
}
pub(crate) fn shift(key: impl Display) -> Span<'static> {
modifier_span(SHIFT_PREFIX, key)
}
pub(crate) fn plain(key: impl Display) -> Span<'static> {
Span::styled(format!("{key}"), key_hint_style())
}
+3 -185
View File
@@ -12,10 +12,8 @@ use codex_core::RolloutRecorder;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config::GPT_5_CODEX_MEDIUM_MODEL;
use codex_core::config::find_codex_home;
use codex_core::config::load_config_as_toml_with_cli_overrides;
use codex_core::config::persist_model_selection;
use codex_core::find_conversation_path_by_id_str;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
@@ -54,7 +52,6 @@ pub mod live_wrap;
mod markdown;
mod markdown_render;
mod markdown_stream;
mod new_model_popup;
pub mod onboarding;
mod pager_overlay;
mod render;
@@ -79,14 +76,11 @@ pub mod test_backend;
#[cfg(not(debug_assertions))]
mod updates;
use crate::new_model_popup::ModelUpgradeDecision;
use crate::new_model_popup::run_model_upgrade_popup;
use crate::onboarding::TrustDirectorySelection;
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
use crate::onboarding::onboarding_screen::run_onboarding_app;
use crate::tui::Tui;
pub use cli::Cli;
use codex_core::internal_storage::InternalStorage;
// (tests access modules directly within the crate)
@@ -204,8 +198,6 @@ pub async fn run_main(
cli_profile_override,
)?;
let internal_storage = InternalStorage::load(&config.codex_home);
let log_dir = codex_core::config::log_dir(&config)?;
std::fs::create_dir_all(&log_dir)?;
// Open (or create) your log file, appending to it.
@@ -249,21 +241,14 @@ pub async fn run_main(
let _ = tracing_subscriber::registry().with(file_layer).try_init();
run_ratatui_app(
cli,
config,
internal_storage,
active_profile,
should_show_trust_screen,
)
.await
.map_err(|err| std::io::Error::other(err.to_string()))
run_ratatui_app(cli, config, active_profile, should_show_trust_screen)
.await
.map_err(|err| std::io::Error::other(err.to_string()))
}
async fn run_ratatui_app(
cli: Cli,
config: Config,
mut internal_storage: InternalStorage,
active_profile: Option<String>,
should_show_trust_screen: bool,
) -> color_eyre::Result<AppExitInfo> {
@@ -389,36 +374,6 @@ async fn run_ratatui_app(
resume_picker::ResumeSelection::StartFresh
};
if should_show_model_rollout_prompt(
&cli,
&config,
active_profile.as_deref(),
internal_storage.gpt_5_codex_model_prompt_seen,
) {
internal_storage.gpt_5_codex_model_prompt_seen = true;
if let Err(e) = internal_storage.persist().await {
error!("Failed to persist internal storage: {e:?}");
}
let upgrade_decision = run_model_upgrade_popup(&mut tui).await?;
let switch_to_new_model = upgrade_decision == ModelUpgradeDecision::Switch;
if switch_to_new_model {
config.model = GPT_5_CODEX_MEDIUM_MODEL.to_owned();
config.model_reasoning_effort = None;
if let Err(e) = persist_model_selection(
&config.codex_home,
active_profile.as_deref(),
&config.model,
config.model_reasoning_effort,
)
.await
{
error!("Failed to persist model selection: {e:?}");
}
}
}
let Cli { prompt, images, .. } = cli;
let app_result = App::run(
@@ -532,140 +487,3 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool
login_status == LoginStatus::NotAuthenticated
}
fn should_show_model_rollout_prompt(
cli: &Cli,
config: &Config,
active_profile: Option<&str>,
gpt_5_codex_model_prompt_seen: bool,
) -> bool {
let login_status = get_login_status(config);
active_profile.is_none()
&& cli.model.is_none()
&& !gpt_5_codex_model_prompt_seen
&& config.model_provider.requires_openai_auth
&& matches!(login_status, LoginStatus::AuthMode(AuthMode::ChatGPT))
&& !cli.oss
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use codex_core::auth::AuthDotJson;
use codex_core::auth::get_auth_file;
use codex_core::auth::login_with_api_key;
use codex_core::auth::write_auth_json;
use codex_core::token_data::IdTokenInfo;
use codex_core::token_data::TokenData;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
fn get_next_codex_home() -> PathBuf {
static NEXT_CODEX_HOME_ID: AtomicUsize = AtomicUsize::new(0);
let mut codex_home = std::env::temp_dir();
let unique_suffix = format!(
"codex_tui_test_{}_{}",
std::process::id(),
NEXT_CODEX_HOME_ID.fetch_add(1, Ordering::Relaxed)
);
codex_home.push(unique_suffix);
codex_home
}
fn make_config() -> Config {
// Create a unique CODEX_HOME per test to isolate auth.json writes.
let codex_home = get_next_codex_home();
std::fs::create_dir_all(&codex_home).expect("create unique CODEX_HOME");
Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home,
)
.expect("load default config")
}
/// Test helper to write an `auth.json` with the requested auth mode into the
/// provided CODEX_HOME directory. This ensures `get_login_status()` reads the
/// intended mode deterministically.
fn set_auth_method(codex_home: &std::path::Path, mode: AuthMode) {
match mode {
AuthMode::ApiKey => {
login_with_api_key(codex_home, "sk-test-key").expect("write api key auth.json");
}
AuthMode::ChatGPT => {
// Minimal valid JWT payload: header.payload.signature (all base64url, no padding)
const FAKE_JWT: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.e30.c2ln"; // {"alg":"none","typ":"JWT"}.{}."sig"
let mut id_info = IdTokenInfo::default();
id_info.raw_jwt = FAKE_JWT.to_string();
let auth = AuthDotJson {
openai_api_key: None,
tokens: Some(TokenData {
id_token: id_info,
access_token: "access-token".to_string(),
refresh_token: "refresh-token".to_string(),
account_id: None,
}),
last_refresh: None,
};
let file = get_auth_file(codex_home);
write_auth_json(&file, &auth).expect("write chatgpt auth.json");
}
}
}
#[test]
fn shows_login_when_not_authenticated() {
let cfg = make_config();
assert!(should_show_login_screen(
LoginStatus::NotAuthenticated,
&cfg
));
}
#[test]
fn shows_model_rollout_prompt_for_default_model() {
let cli = Cli::parse_from(["codex"]);
let cfg = make_config();
set_auth_method(&cfg.codex_home, AuthMode::ChatGPT);
assert!(should_show_model_rollout_prompt(&cli, &cfg, None, false,));
}
#[test]
fn hides_model_rollout_prompt_when_api_auth_mode() {
let cli = Cli::parse_from(["codex"]);
let cfg = make_config();
set_auth_method(&cfg.codex_home, AuthMode::ApiKey);
assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, false,));
}
#[test]
fn hides_model_rollout_prompt_when_marked_seen() {
let cli = Cli::parse_from(["codex"]);
let cfg = make_config();
set_auth_method(&cfg.codex_home, AuthMode::ChatGPT);
assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, true,));
}
#[test]
fn hides_model_rollout_prompt_when_cli_overrides_model() {
let cli = Cli::parse_from(["codex", "--model", "gpt-4.1"]);
let cfg = make_config();
set_auth_method(&cfg.codex_home, AuthMode::ChatGPT);
assert!(!should_show_model_rollout_prompt(&cli, &cfg, None, false,));
}
#[test]
fn hides_model_rollout_prompt_when_profile_active() {
let cli = Cli::parse_from(["codex"]);
let cfg = make_config();
set_auth_method(&cfg.codex_home, AuthMode::ChatGPT);
assert!(!should_show_model_rollout_prompt(
&cli,
&cfg,
Some("gpt5"),
false,
));
}
}
-179
View File
@@ -1,179 +0,0 @@
use crate::ascii_animation::AsciiAnimation;
use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use tokio_stream::StreamExt;
const MIN_ANIMATION_HEIGHT: u16 = 24;
const MIN_ANIMATION_WIDTH: u16 = 60;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ModelUpgradeDecision {
Switch,
KeepCurrent,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ModelUpgradeOption {
TryNewModel,
KeepCurrent,
}
struct ModelUpgradePopup {
highlighted: ModelUpgradeOption,
decision: Option<ModelUpgradeDecision>,
animation: AsciiAnimation,
}
impl ModelUpgradePopup {
fn new(request_frame: FrameRequester) -> Self {
Self {
highlighted: ModelUpgradeOption::TryNewModel,
decision: None,
animation: AsciiAnimation::new(request_frame),
}
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => self.highlight(ModelUpgradeOption::TryNewModel),
KeyCode::Down | KeyCode::Char('j') => self.highlight(ModelUpgradeOption::KeepCurrent),
KeyCode::Char('1') => self.select(ModelUpgradeOption::TryNewModel),
KeyCode::Char('2') => self.select(ModelUpgradeOption::KeepCurrent),
KeyCode::Enter => self.select(self.highlighted),
KeyCode::Esc => self.select(ModelUpgradeOption::KeepCurrent),
KeyCode::Char('.') => {
if key_event.modifiers.contains(KeyModifiers::CONTROL) {
let _ = self.animation.pick_random_variant();
}
}
_ => {}
}
}
fn highlight(&mut self, option: ModelUpgradeOption) {
if self.highlighted != option {
self.highlighted = option;
self.animation.request_frame();
}
}
fn select(&mut self, option: ModelUpgradeOption) {
self.decision = Some(option.into());
self.animation.request_frame();
}
}
impl From<ModelUpgradeOption> for ModelUpgradeDecision {
fn from(option: ModelUpgradeOption) -> Self {
match option {
ModelUpgradeOption::TryNewModel => ModelUpgradeDecision::Switch,
ModelUpgradeOption::KeepCurrent => ModelUpgradeDecision::KeepCurrent,
}
}
}
impl WidgetRef for &ModelUpgradePopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
self.animation.schedule_next_frame();
// Skip the animation entirely when the viewport is too small so we don't clip frames.
let show_animation =
area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH;
let mut lines: Vec<Line> = Vec::new();
if show_animation {
let frame = self.animation.current_frame();
lines.extend(frame.lines().map(Into::into));
// Spacer between animation and text content.
lines.push("".into());
}
lines.push(Line::from(vec![
" ".into(),
"Introducing GPT-5-Codex".bold(),
]));
lines.push("".into());
lines.push(
" GPT-5-Codex works faster through easy tasks and harder on complex tasks,".into(),
);
lines.push(" improves on code quality, and is more steerable with AGENTS.md.".into());
lines.push("".into());
let create_option =
|index: usize, option: ModelUpgradeOption, text: &str| -> Line<'static> {
if self.highlighted == option {
Line::from(vec![
format!("> {}. ", index + 1).cyan(),
text.to_owned().cyan(),
])
} else {
format!(" {}. {text}", index + 1).into()
}
};
lines.push(create_option(
0,
ModelUpgradeOption::TryNewModel,
"Try the new GPT-5-Codex model",
));
lines.push("".into());
lines.push(create_option(
1,
ModelUpgradeOption::KeepCurrent,
"Continue using current model",
));
lines.push("".into());
lines.push(
" Press Enter to confirm or Esc to keep your current model"
.dim()
.into(),
);
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
}
}
pub(crate) async fn run_model_upgrade_popup(tui: &mut Tui) -> Result<ModelUpgradeDecision> {
let mut popup = ModelUpgradePopup::new(tui.frame_requester());
tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&popup, frame.area());
})?;
let events = tui.event_stream();
tokio::pin!(events);
while popup.decision.is_none() {
if let Some(event) = events.next().await {
match event {
TuiEvent::Key(key_event) => popup.handle_key_event(key_event),
TuiEvent::Draw => {
let _ = tui.draw(u16::MAX, |frame| {
frame.render_widget_ref(&popup, frame.area());
});
}
_ => {}
}
} else {
break;
}
}
Ok(popup.decision.unwrap_or(ModelUpgradeDecision::KeepCurrent))
}
+2 -2
View File
@@ -34,7 +34,7 @@ pub(crate) struct StatusIndicatorWidget {
// Format elapsed seconds into a compact human-friendly form used by the status line.
// Examples: 0s, 59s, 1m 00s, 59m 59s, 1h 00m 00s, 2h 03m 09s
fn fmt_elapsed_compact(elapsed_secs: u64) -> String {
pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String {
if elapsed_secs < 60 {
return format!("{elapsed_secs}s");
}
@@ -142,7 +142,7 @@ impl StatusIndicatorWidget {
elapsed.as_secs()
}
fn elapsed_seconds(&self) -> u64 {
pub fn elapsed_seconds(&self) -> u64 {
self.elapsed_seconds_at(Instant::now())
}
}