Skip to content
Merged
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
34 changes: 34 additions & 0 deletions codex-rs/tui2/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ use codex_ansi_escape::ansi_escape_line;
use codex_core::AuthManager;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
#[cfg(target_os = "windows")]
use codex_core::features::Feature;
Expand Down Expand Up @@ -1991,6 +1992,39 @@ impl App {
}
}
}
AppEvent::UpdateFeatureFlags { updates } => {
if updates.is_empty() {
return Ok(AppRunControl::Continue);
}

let mut builder = ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(self.active_profile.as_deref());
for (feature, enabled) in &updates {
let key = feature.key();
if *enabled {
self.config.features.enable(*feature);
self.chat_widget.set_feature_enabled(*feature, true);
builder = builder.set_feature_enabled(key, true);
} else {
self.config.features.disable(*feature);
self.chat_widget.set_feature_enabled(*feature, false);
if feature.default_enabled() {
builder = builder.set_feature_enabled(key, false);
} else {
builder = builder.with_edits([ConfigEdit::ClearPath {
segments: vec!["features".to_string(), key.to_string()],
}]);
}
}
}

if let Err(err) = builder.apply().await {
tracing::error!(error = %err, "failed to persist feature flags");
self.chat_widget.add_error_message(format!(
"Failed to update experimental features: {err}"
));
}
}
AppEvent::SkipNextWorldWritableScan => {
self.skip_world_writable_scan_once = true;
}
Expand Down
6 changes: 6 additions & 0 deletions codex-rs/tui2/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use std::path::PathBuf;

use codex_common::approval_presets::ApprovalPreset;
use codex_core::features::Feature;
use codex_core::protocol::Event;
use codex_core::protocol::RateLimitSnapshot;
use codex_file_search::FileMatch;
Expand Down Expand Up @@ -168,6 +169,11 @@ pub(crate) enum AppEvent {
/// Update the current sandbox policy in the running app and widget.
UpdateSandboxPolicy(SandboxPolicy),

/// Update feature flags and persist them to config.
UpdateFeatureFlags {
updates: Vec<(Feature, bool)>,
},

/// Update whether the full access warning prompt has been acknowledged.
UpdateFullAccessWarningAcknowledged(bool),

Expand Down
12 changes: 11 additions & 1 deletion codex-rs/tui2/src/bottom_pane/command_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,17 @@ mod tests {
CommandItem::UserPrompt(_) => None,
})
.collect();
assert_eq!(cmds, vec!["model", "resume", "compact", "mention", "mcp"]);
assert_eq!(
cmds,
vec![
"model",
"experimental",
"resume",
"compact",
"mention",
"mcp"
]
);
}

#[test]
Expand Down
293 changes: 293 additions & 0 deletions codex-rs/tui2/src/bottom_pane/experimental_features_view.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;

use codex_core::features::Feature;

use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;

use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;

pub(crate) struct BetaFeatureItem {
pub(crate) feature: Feature,
pub(crate) name: String,
pub(crate) description: String,
pub(crate) enabled: bool,
}

pub(crate) struct ExperimentalFeaturesView {
features: Vec<BetaFeatureItem>,
state: ScrollState,
complete: bool,
app_event_tx: AppEventSender,
header: Box<dyn Renderable>,
footer_hint: Line<'static>,
}

impl ExperimentalFeaturesView {
pub(crate) fn new(features: Vec<BetaFeatureItem>, app_event_tx: AppEventSender) -> Self {
let mut header = ColumnRenderable::new();
header.push(Line::from("Experimental features".bold()));
header.push(Line::from(
"Toggle beta features. Changes are saved to config.toml.".dim(),
));

let mut view = Self {
features,
state: ScrollState::new(),
complete: false,
app_event_tx,
header: Box::new(header),
footer_hint: experimental_popup_hint_line(),
};
view.initialize_selection();
view
}

fn initialize_selection(&mut self) {
if self.visible_len() == 0 {
self.state.selected_idx = None;
} else if self.state.selected_idx.is_none() {
self.state.selected_idx = Some(0);
}
}

fn visible_len(&self) -> usize {
self.features.len()
}

fn build_rows(&self) -> Vec<GenericDisplayRow> {
let mut rows = Vec::with_capacity(self.features.len());
let selected_idx = self.state.selected_idx;
for (idx, item) in self.features.iter().enumerate() {
let prefix = if selected_idx == Some(idx) {
'›'
} else {
' '
};
let marker = if item.enabled { 'x' } else { ' ' };
let name = format!("{prefix} [{marker}] {}", item.name);
rows.push(GenericDisplayRow {
name,
display_shortcut: None,
match_indices: None,
description: Some(item.description.clone()),
wrap_indent: None,
});
}

rows
}

fn move_up(&mut self) {
let len = self.visible_len();
if len == 0 {
return;
}
self.state.move_up_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}

fn move_down(&mut self) {
let len = self.visible_len();
if len == 0 {
return;
}
self.state.move_down_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}

fn toggle_selected(&mut self) {
let Some(selected_idx) = self.state.selected_idx else {
return;
};

if let Some(item) = self.features.get_mut(selected_idx) {
item.enabled = !item.enabled;
}
}

fn rows_width(total_width: u16) -> u16 {
total_width.saturating_sub(2)
}
}

impl BottomPaneView for ExperimentalFeaturesView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Up, ..
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} => self.move_down(),
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.toggle_selected(),
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.on_ctrl_c();
}
_ => {}
}
}

fn is_complete(&self) -> bool {
self.complete
}

fn on_ctrl_c(&mut self) -> CancellationEvent {
if !self.features.is_empty() {
let updates = self
.features
.iter()
.map(|item| (item.feature, item.enabled))
.collect();
self.app_event_tx
.send(AppEvent::UpdateFeatureFlags { updates });
}

self.complete = true;
CancellationEvent::Handled
}
}

impl Renderable for ExperimentalFeaturesView {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}

let [content_area, footer_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(area);

Block::default()
.style(user_message_style())
.render(content_area, buf);

let header_height = self
.header
.desired_height(content_area.width.saturating_sub(4));
let rows = self.build_rows();
let rows_width = Self::rows_width(content_area.width);
let rows_height = measure_rows_height(
&rows,
&self.state,
MAX_POPUP_ROWS,
rows_width.saturating_add(1),
);
let [header_area, _, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(1, 2)));

self.header.render(header_area, buf);

if list_area.height > 0 {
let render_area = Rect {
x: list_area.x.saturating_sub(2),
y: list_area.y,
width: rows_width.max(1),
height: list_area.height,
};
render_rows(
render_area,
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
" No experimental features available for now",
);
}

let hint_area = Rect {
x: footer_area.x + 2,
y: footer_area.y,
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
};
self.footer_hint.clone().dim().render(hint_area, buf);
}

fn desired_height(&self, width: u16) -> u16 {
let rows = self.build_rows();
let rows_width = Self::rows_width(width);
let rows_height = measure_rows_height(
&rows,
&self.state,
MAX_POPUP_ROWS,
rows_width.saturating_add(1),
);

let mut height = self.header.desired_height(width.saturating_sub(4));
height = height.saturating_add(rows_height + 3);
height.saturating_add(1)
}
}

fn experimental_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Enter).into(),
" to toggle or ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to save for next conversation".into(),
])
}
3 changes: 3 additions & 0 deletions codex-rs/tui2/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
pub mod custom_prompt_view;
mod experimental_features_view;
mod file_search_popup;
mod footer;
mod list_selection_view;
Expand Down Expand Up @@ -99,6 +100,8 @@ pub(crate) use chat_composer::InputResult;
use codex_protocol::custom_prompts::CustomPrompt;

use crate::status_indicator_widget::StatusIndicatorWidget;
pub(crate) use experimental_features_view::BetaFeatureItem;
pub(crate) use experimental_features_view::ExperimentalFeaturesView;
pub(crate) use list_selection_view::SelectionAction;
pub(crate) use list_selection_view::SelectionItem;

Expand Down
Loading
Loading