add approvals slash command, models option

This commit is contained in:
easong-openai
2025-08-02 13:55:53 -07:00
parent aec9a8b9b4
commit b23cddd1d9
12 changed files with 969 additions and 225 deletions

View File

@@ -306,11 +306,21 @@ impl App<'_> {
widget.update_model_and_reconfigure(model);
}
}
AppEvent::SelectApprovalPolicy(mode) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.update_approval_policy_and_reconfigure(mode);
}
}
AppEvent::OpenModelSelector => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_model_selector();
}
}
AppEvent::OpenApprovalSelector => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_approval_selector();
}
}
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::GitWarning { .. } => {}
@@ -403,6 +413,9 @@ impl App<'_> {
SlashCommand::Model => {
// Disallow `/model` without arguments; no action.
}
SlashCommand::Approvals => {
// Disallow `/approvals` without arguments; no action.
}
},
AppEvent::DispatchCommandWithArgs(command, args) => match command {
SlashCommand::Model => {
@@ -415,6 +428,22 @@ impl App<'_> {
}
}
}
SlashCommand::Approvals => {
let arg = args.trim();
if let AppState::Chat { widget } = &mut self.app_state {
let normalized = strip_surrounding_quotes(arg).trim().to_string();
if !normalized.is_empty() {
use crate::bottom_pane::selection_popup::parse_approval_mode_token;
if let Some(mode) = parse_approval_mode_token(&normalized) {
widget.update_approval_policy_and_reconfigure(mode);
} else {
widget.add_diff_output(format!(
"`/approvals {normalized}` — unrecognized approval mode"
));
}
}
}
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
// Ignore args; forward to the existing no-args handler
@@ -567,8 +596,10 @@ mod tests {
fn strip_surrounding_quotes_cases() {
let cases = vec![
("o3", "o3"),
("\"codex-mini-latest\"", "codex-mini-latest"),
(" \"codex-mini-latest\" ", "codex-mini-latest"),
("another_model", "another_model"),
("quoted", "quoted"),
("“smart”", "smart"),
];
for (input, expected) in cases {
assert_eq!(strip_surrounding_quotes(input), expected.to_string());

View File

@@ -4,6 +4,7 @@ use crossterm::event::KeyEvent;
use ratatui::text::Line;
use crate::slash_command::SlashCommand;
use codex_core::protocol::AskForApproval;
#[allow(clippy::large_enum_variant)]
pub(crate) enum AppEvent {
@@ -58,4 +59,10 @@ pub(crate) enum AppEvent {
/// Request the app to open the model selector (populate options and show popup).
OpenModelSelector,
/// User selected an approval policy from the approvals dropdown.
SelectApprovalPolicy(AskForApproval),
/// Request the app to open the approval selector (populate options and show popup).
OpenApprovalSelector,
}

View File

@@ -0,0 +1,110 @@
use codex_core::protocol::AskForApproval;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
const MAX_RESULTS: usize = 6;
use super::selection_list::{SelectionItem, SelectionList};
use super::selection_popup_common::{render_rows, GenericDisplayRow};
/// Popup for selecting the approval mode at runtime.
pub(crate) struct ApprovalSelectionPopup {
list: SelectionList<AskForApproval>,
}
impl ApprovalSelectionPopup {
pub(crate) fn new(current: AskForApproval, mut options: Vec<AskForApproval>) -> Self {
options.dedup();
let items = build_items(current, options);
Self { list: SelectionList::new(items) }
}
pub(crate) fn set_options(&mut self, current: AskForApproval, mut options: Vec<AskForApproval>) {
options.dedup();
self.list.set_items(build_items(current, options));
}
pub(crate) fn set_query(&mut self, query: &str) { self.list.set_query(query); }
pub(crate) fn move_up(&mut self) { self.list.move_up(); }
pub(crate) fn move_down(&mut self) { self.list.move_down(); }
pub(crate) fn selected_mode(&self) -> Option<AskForApproval> { self.list.selected_value() }
pub(crate) fn calculate_required_height(&self) -> u16 {
self.visible_rows().len().clamp(1, MAX_RESULTS) as u16
}
fn visible_rows(&self) -> Vec<GenericDisplayRow> {
self.list.visible_rows().into_iter().map(|(row, _)| row).collect()
}
}
fn display_name(mode: AskForApproval) -> &'static str {
match mode {
AskForApproval::UnlessTrusted => "Prompt on Writes",
AskForApproval::OnFailure => "Auto",
AskForApproval::Never => "Deny all",
}
}
fn description_for(mode: AskForApproval) -> &'static str {
match mode {
AskForApproval::UnlessTrusted =>
"ask for approval for every write in the CWD and every sandbox breach",
AskForApproval::OnFailure =>
"only ask for commands that would breach the sandbox",
AskForApproval::Never =>
"deny all writes and commands that would breach the sandbox",
}
}
// Internal rows are produced by the generic SelectionList.
impl WidgetRef for &ApprovalSelectionPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let rows_all: Vec<GenericDisplayRow> = self.visible_rows();
render_rows(area, buf, &rows_all, &self.list.state, MAX_RESULTS);
}
}
/// Parse a freeform string and try to map it to an approval mode.
pub(crate) fn parse_approval_mode_token(s: &str) -> Option<AskForApproval> {
let t = s.trim().to_ascii_lowercase();
match t.as_str() {
"untrusted" | "prompt-on-writes" | "prompt on writes" => Some(AskForApproval::UnlessTrusted),
"on-failure" | "auto" | "full-auto" | "fullauto" | "full" => Some(AskForApproval::OnFailure),
"never" | "deny-all" | "deny all" => Some(AskForApproval::Never),
_ => None,
}
}
fn aliases_for(mode: AskForApproval) -> &'static [&'static str] {
match mode {
AskForApproval::UnlessTrusted => &["untrusted", "prompt-on-writes", "prompt on writes"],
AskForApproval::OnFailure => &["auto", "full-auto", "on-failure", "fullauto", "full"],
AskForApproval::Never => &["never", "deny-all", "deny all"],
}
}
fn build_items(
current: AskForApproval,
options: Vec<AskForApproval>,
) -> Vec<SelectionItem<AskForApproval>> {
let mut items: Vec<SelectionItem<AskForApproval>> = Vec::new();
let current_item = SelectionItem::new(current, display_name(current).to_string())
.with_description(Some(description_for(current).to_string()))
.with_aliases(aliases_for(current).iter().map(|s| s.to_string()).collect())
.mark_current(true);
items.push(current_item);
for m in options.into_iter().filter(|m| *m != current) {
items.push(
SelectionItem::new(m, display_name(m).to_string())
.with_description(Some(description_for(m).to_string()))
.with_aliases(aliases_for(m).iter().map(|s| s.to_string()).collect()),
);
}
items
}

View File

@@ -19,7 +19,10 @@ use tui_textarea::TextArea;
use super::chat_composer_history::ChatComposerHistory;
use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup;
use super::model_selection_popup::ModelSelectionPopup;
use super::selection_popup::SelectionKind;
use super::selection_popup::SelectionPopup;
use super::selection_popup::SelectionValue;
use super::selection_popup::parse_approval_mode_token;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -55,7 +58,7 @@ enum ActivePopup {
None,
Command(CommandPopup),
File(FileSearchPopup),
Model(ModelSelectionPopup),
Selection(SelectionPopup),
}
impl ChatComposer<'_> {
@@ -181,7 +184,7 @@ impl ChatComposer<'_> {
ActivePopup::None => 1u16,
ActivePopup::Command(c) => c.calculate_required_height(),
ActivePopup::File(c) => c.calculate_required_height(),
ActivePopup::Model(c) => c.calculate_required_height(),
ActivePopup::Selection(c) => c.calculate_required_height(),
}
}
@@ -279,12 +282,12 @@ impl ChatComposer<'_> {
/// Open or update the model-selection popup with the provided options.
pub(crate) fn open_model_selector(&mut self, current_model: &str, options: Vec<String>) {
match &mut self.active_popup {
ActivePopup::Model(popup) => {
popup.set_options(current_model, options);
ActivePopup::Selection(popup) if popup.kind() == SelectionKind::Model => {
*popup = SelectionPopup::new_model(current_model, options);
}
_ => {
self.active_popup =
ActivePopup::Model(ModelSelectionPopup::new(current_model, options));
ActivePopup::Selection(SelectionPopup::new_model(current_model, options));
}
}
// If the composer currently contains a `/model` command, initialize the
@@ -298,7 +301,38 @@ impl ChatComposer<'_> {
.unwrap_or("");
if let Some((cmd_token, args)) = Self::parse_slash_command_and_args_from_line(first_line) {
if cmd_token == SlashCommand::Model.command() {
if let ActivePopup::Model(popup) = &mut self.active_popup {
if let ActivePopup::Selection(popup) = &mut self.active_popup {
popup.set_query(&args);
}
}
}
}
/// Open or update the approval-mode selection popup with the provided options.
pub(crate) fn open_approval_selector(
&mut self,
current: codex_core::protocol::AskForApproval,
options: Vec<codex_core::protocol::AskForApproval>,
) {
match &mut self.active_popup {
ActivePopup::Selection(popup) if popup.kind() == SelectionKind::Approval => {
*popup = SelectionPopup::new_approvals(current, options);
}
_ => {
self.active_popup =
ActivePopup::Selection(SelectionPopup::new_approvals(current, options));
}
}
// Initialize the popup's query from the arguments to `/approvals`, if present.
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
if let Some((cmd_token, args)) = Self::parse_slash_command_and_args_from_line(first_line) {
if cmd_token == SlashCommand::Approvals.command() {
if let ActivePopup::Selection(popup) = &mut self.active_popup {
popup.set_query(&args);
}
}
@@ -310,15 +344,14 @@ impl ChatComposer<'_> {
let result = match &mut self.active_popup {
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
ActivePopup::Model(_) => self.handle_key_event_with_model_popup(key_event),
ActivePopup::Selection(_) => self.handle_key_event_with_selection_popup(key_event),
ActivePopup::None => self.handle_key_event_without_popup(key_event),
};
// Update (or hide/show) popup after processing the key.
match &self.active_popup {
ActivePopup::Model(_) => {
// Only keep model popup in sync when active; do not interfere with other popups.
self.sync_model_popup();
ActivePopup::Selection(_) => {
self.sync_selection_popup();
}
ActivePopup::Command(_) => {
self.sync_command_popup();
@@ -396,9 +429,11 @@ impl ChatComposer<'_> {
String::new()
};
// Special case: selecting `/model` without args should open the model selector.
// Special cases: selecting `/model` or `/approvals` without args opens a submenu.
if *cmd == SlashCommand::Model && args.trim().is_empty() {
self.app_event_tx.send(AppEvent::OpenModelSelector);
} else if *cmd == SlashCommand::Approvals && args.trim().is_empty() {
self.app_event_tx.send(AppEvent::OpenApprovalSelector);
} else {
// Send command + args to the app layer.
self.app_event_tx
@@ -465,8 +500,11 @@ impl ChatComposer<'_> {
}
/// Handle key events when model selection popup is visible.
fn handle_key_event_with_model_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let ActivePopup::Model(popup) = &mut self.active_popup else {
fn handle_key_event_with_selection_popup(
&mut self,
key_event: KeyEvent,
) -> (InputResult, bool) {
let ActivePopup::Selection(popup) = &mut self.active_popup else {
unreachable!();
};
@@ -480,7 +518,7 @@ impl ChatComposer<'_> {
(InputResult::None, true)
}
Input { key: Key::Esc, .. } => {
// Hide model popup; keep composer content unchanged.
// Hide selection popup; keep composer content unchanged.
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
@@ -491,8 +529,15 @@ impl ChatComposer<'_> {
shift: false,
}
| Input { key: Key::Tab, .. } => {
if let Some(model) = popup.selected_model() {
self.app_event_tx.send(AppEvent::SelectModel(model));
if let Some(value) = popup.selected_value() {
match value {
SelectionValue::Model(m) => {
self.app_event_tx.send(AppEvent::SelectModel(m))
}
SelectionValue::Approval(mode) => {
self.app_event_tx.send(AppEvent::SelectApprovalPolicy(mode))
}
}
// Clear composer input and close the popup.
self.textarea.select_all();
self.textarea.cut();
@@ -500,8 +545,7 @@ impl ChatComposer<'_> {
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
// No selection in the list: treat the typed argument as the model name.
// Extract arguments after `/model` from the first line.
// No selection in the list: attempt to parse typed arguments for the appropriate kind.
let first_line = self
.textarea
.lines()
@@ -509,28 +553,38 @@ impl ChatComposer<'_> {
.map(|s| s.as_str())
.unwrap_or("");
let args = if let Some((cmd_token, args)) =
if let Some((cmd_token, args)) =
Self::parse_slash_command_and_args_from_line(first_line)
{
if cmd_token == SlashCommand::Model.command() {
args
} else {
String::new()
let args = args.trim().to_string();
if !args.is_empty() {
match popup.kind() {
SelectionKind::Model if cmd_token == SlashCommand::Model.command() => {
self.app_event_tx.send(AppEvent::DispatchCommandWithArgs(
SlashCommand::Model,
args,
));
self.textarea.select_all();
self.textarea.cut();
self.pending_pastes.clear();
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
SelectionKind::Approval
if cmd_token == SlashCommand::Approvals.command() =>
{
if let Some(mode) = parse_approval_mode_token(&args) {
self.app_event_tx.send(AppEvent::SelectApprovalPolicy(mode));
self.textarea.select_all();
self.textarea.cut();
self.pending_pastes.clear();
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
}
_ => {}
}
}
} else {
String::new()
};
if !args.trim().is_empty() {
// Dispatch as a command with args so normalization is applied centrally.
self.app_event_tx
.send(AppEvent::DispatchCommandWithArgs(SlashCommand::Model, args));
// Clear composer input and close the popup.
self.textarea.select_all();
self.textarea.cut();
self.pending_pastes.clear();
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
(InputResult::None, false)
}
@@ -538,6 +592,8 @@ impl ChatComposer<'_> {
}
}
// Approval-specific handler removed; unified selection handler is used.
/// Extract the `@token` that the cursor is currently positioned on, if any.
///
/// The returned string **does not** include the leading `@`.
@@ -781,6 +837,20 @@ impl ChatComposer<'_> {
false
};
// And similarly for `/approvals `.
let should_open_approval_selector = if let Some(stripped) = first_line.strip_prefix('/') {
let token = stripped.trim_start();
let cmd_token = token.split_whitespace().next().unwrap_or("");
if cmd_token == SlashCommand::Approvals.command() {
let rest = &token[cmd_token.len()..];
rest.chars().next().is_some_and(|c| c.is_whitespace())
} else {
false
}
} else {
false
};
match &mut self.active_popup {
ActivePopup::Command(popup) => {
if input_starts_with_slash {
@@ -788,6 +858,9 @@ impl ChatComposer<'_> {
// Switch away from command popup and request opening the model selector.
self.active_popup = ActivePopup::None;
self.app_event_tx.send(AppEvent::OpenModelSelector);
} else if should_open_approval_selector {
self.active_popup = ActivePopup::None;
self.app_event_tx.send(AppEvent::OpenApprovalSelector);
} else {
popup.on_composer_text_change(first_line.clone());
}
@@ -802,6 +875,8 @@ impl ChatComposer<'_> {
// Always allow opening the model selector even if the user previously
// dismissed the slash popup for this token.
self.app_event_tx.send(AppEvent::OpenModelSelector);
} else if should_open_approval_selector {
self.app_event_tx.send(AppEvent::OpenApprovalSelector);
} else {
// Avoid immediate reopen of the slash popup if it was just dismissed for
// this exact command token.
@@ -853,9 +928,8 @@ impl ChatComposer<'_> {
self.dismissed_file_popup_token = None;
}
/// Synchronize the model-selection popup filter with the current composer text.
/// When the first line starts with `/model`, everything after the command becomes the query.
fn sync_model_popup(&mut self) {
/// Synchronize the selection popup filter with the current composer text.
fn sync_selection_popup(&mut self) {
let first_line = self
.textarea
.lines()
@@ -863,17 +937,21 @@ impl ChatComposer<'_> {
.map(|s| s.as_str())
.unwrap_or("");
// Expect `/model` as the first token on the first line.
if let Some((cmd_token, args)) = Self::parse_slash_command_and_args_from_line(first_line) {
if cmd_token == SlashCommand::Model.command() {
if let ActivePopup::Model(popup) = &mut self.active_popup {
popup.set_query(&args);
match &mut self.active_popup {
ActivePopup::Selection(popup) if popup.kind() == SelectionKind::Model => {
if cmd_token == SlashCommand::Model.command() {
popup.set_query(&args);
}
}
return;
ActivePopup::Selection(popup) if popup.kind() == SelectionKind::Approval => {
if cmd_token == SlashCommand::Approvals.command() {
popup.set_query(&args);
}
}
_ => {}
}
}
// If the line is not `/model`, do nothing keep the model popup open
// when it was opened programmatically via the slash menu.
}
/// Parse a leading "/command" and return (command_token, args_trimmed_left).
@@ -925,7 +1003,7 @@ impl WidgetRef for &ChatComposer<'_> {
popup.render(popup_rect, buf);
self.textarea.render(textarea_rect, buf);
}
ActivePopup::Model(popup) => {
ActivePopup::Selection(popup) => {
let popup_height = popup.calculate_required_height();
let popup_height = popup_height.min(area.height);
@@ -1409,7 +1487,9 @@ mod tests {
composer.handle_paste("/mo".to_string());
// Press Enter to select the command (no args)
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// We should emit OpenModelSelector
@@ -1425,14 +1505,19 @@ mod tests {
Err(TryRecvError::Disconnected) => break,
}
}
assert!(saw_open, "pressing Enter on /model should open model selector");
assert!(
saw_open,
"pressing Enter on /model should open model selector"
);
}
#[test]
fn enter_on_model_selector_selects_current_row() {
use crate::app_event::AppEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::sync::mpsc::TryRecvError;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
@@ -1462,13 +1547,18 @@ mod tests {
Err(TryRecvError::Disconnected) => break,
}
}
assert!(saw_select, "Enter on model selector should emit SelectModel");
assert!(
saw_select,
"Enter on model selector should emit SelectModel"
);
}
#[test]
fn model_selector_stays_open_on_up_down() {
use crate::bottom_pane::chat_composer::ActivePopup;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
@@ -1483,11 +1573,11 @@ mod tests {
// Press Down; popup should remain visible
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::Model(_)));
assert!(matches!(composer.active_popup, ActivePopup::Selection(_)));
// Press Up; popup should remain visible
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::Model(_)));
assert!(matches!(composer.active_popup, ActivePopup::Selection(_)));
}
#[test]
@@ -1672,4 +1762,84 @@ mod tests {
]
);
}
#[test]
fn selecting_approvals_in_slash_menu_opens_selector() {
use crate::app_event::AppEvent;
use std::sync::mpsc::TryRecvError;
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Type a prefix that uniquely selects the approvals command in slash popup
composer.handle_paste("/appr".to_string());
// Press Enter to select the command (no args)
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// We should emit OpenApprovalSelector
let mut saw_open = false;
loop {
match rx.try_recv() {
Ok(AppEvent::OpenApprovalSelector) => {
saw_open = true;
break;
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert!(
saw_open,
"pressing Enter on /approvals should open selector"
);
}
#[test]
fn enter_on_approvals_selector_selects_current_row() {
use crate::app_event::AppEvent;
use codex_core::protocol::AskForApproval;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::sync::mpsc::TryRecvError;
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Open the approvals selector directly
let options = vec![
AskForApproval::UnlessTrusted,
AskForApproval::OnFailure,
AskForApproval::Never,
];
composer.open_approval_selector(AskForApproval::Never, options);
// Press Enter to select the currently highlighted row (first visible)
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// We should receive a SelectApprovalPolicy event.
let mut saw_select = false;
loop {
match rx.try_recv() {
Ok(AppEvent::SelectApprovalPolicy(_)) => {
saw_select = true;
break;
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert!(
saw_select,
"Enter on approvals selector should emit SelectApprovalPolicy"
);
}
}

View File

@@ -18,8 +18,10 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
mod file_search_popup;
mod model_selection_popup;
mod scroll_state;
mod selection_list;
pub(crate) mod selection_popup;
mod selection_popup_common;
mod status_indicator_view;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -32,6 +34,7 @@ pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
use approval_modal_view::ApprovalModalView;
use codex_core::protocol::AskForApproval;
use status_indicator_view::StatusIndicatorView;
/// Pane displayed in the lower half of the chat UI.
@@ -78,6 +81,16 @@ impl BottomPane<'_> {
self.request_redraw();
}
/// Show the approval-mode selection popup in the composer.
pub(crate) fn show_approval_selector(
&mut self,
current: AskForApproval,
options: Vec<AskForApproval>,
) {
self.composer.open_approval_selector(current, options);
self.request_redraw();
}
pub fn desired_height(&self, width: u16) -> u16 {
self.active_view
.as_ref()

View File

@@ -1,23 +1,12 @@
use super::scroll_state::ScrollState;
use codex_common::fuzzy_match::fuzzy_indices;
use codex_common::fuzzy_match::fuzzy_match;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Constraint;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
/// Maximum number of options shown in the popup.
const MAX_RESULTS: usize = 8;
@@ -94,10 +83,9 @@ impl ModelSelectionPopup {
/// Currently selected model name, if any.
pub(crate) fn selected_model(&self) -> Option<String> {
let rows = self.visible_rows();
self.state.selected_idx.and_then(|idx| {
rows.get(idx)
.map(|DisplayRow::Model { name, .. }| name.clone())
})
self.state
.selected_idx
.and_then(|idx| rows.get(idx).map(|r| r.name.clone()))
}
/// Preferred height (rows) including border.
@@ -106,163 +94,30 @@ impl ModelSelectionPopup {
}
/// Compute rows to display applying fuzzy filtering and pinning current model.
fn visible_rows(&self) -> Vec<DisplayRow> {
// Build candidate list excluding the current model.
let mut others: Vec<&str> = self
fn visible_rows(&self) -> Vec<GenericDisplayRow> {
// Rebuild items on the fly to use the unified selection list behavior.
use super::selection_list::{SelectionItem, SelectionList};
let mut items: Vec<SelectionItem<String>> = Vec::new();
items.push(SelectionItem::new(self.current_model.clone(), self.current_model.clone()).mark_current(true));
for m in self
.options
.iter()
.map(|s| s.as_str())
.filter(|m| *m != self.current_model)
.collect();
// Keep original ordering for non-search.
if self.query.trim().is_empty() {
let mut rows: Vec<DisplayRow> = Vec::new();
// Current model first.
rows.push(DisplayRow::Model {
name: self.current_model.clone(),
match_indices: None,
is_current: true,
});
for name in others.drain(..) {
rows.push(DisplayRow::Model {
name: name.to_string(),
match_indices: None,
is_current: false,
});
}
return rows;
.filter(|s| *s != &self.current_model)
{
items.push(SelectionItem::new(m.clone(), m.clone()));
}
// Searching: include current model only if it matches.
let mut rows: Vec<DisplayRow> = Vec::new();
if let Some(indices) = fuzzy_indices(&self.current_model, &self.query) {
rows.push(DisplayRow::Model {
name: self.current_model.clone(),
match_indices: Some(indices),
is_current: true,
});
}
// Fuzzy-match the rest and sort by score, then name, then match tightness.
let mut matches: Vec<(String, Vec<usize>, i32)> = Vec::new();
for name in others.into_iter() {
if let Some((indices, score)) = fuzzy_match(name, &self.query) {
matches.push((name.to_string(), indices, score));
}
}
matches.sort_by(|(a_name, a_idx, a_score), (b_name, b_idx, b_score)| {
a_score
.cmp(b_score)
.then_with(|| a_name.cmp(b_name))
.then_with(|| a_idx.len().cmp(&b_idx.len()))
});
for (name, indices, _score) in matches.into_iter() {
if name != self.current_model {
rows.push(DisplayRow::Model {
name,
match_indices: Some(indices),
is_current: false,
});
}
}
rows
let mut list = SelectionList::new(items);
list.state.selected_idx = self.state.selected_idx;
list.state.scroll_top = self.state.scroll_top;
list.set_query(&self.query);
list.visible_rows().into_iter().map(|(row, _)| row).collect()
}
}
/// Row in the model popup.
enum DisplayRow {
Model {
name: String,
match_indices: Option<Vec<usize>>, // indices to bold (char positions)
is_current: bool,
},
}
impl WidgetRef for &ModelSelectionPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let rows_all = self.visible_rows();
let mut rows: Vec<Row> = Vec::new();
if rows_all.is_empty() {
rows.push(Row::new(vec![Cell::from(Line::from(Span::styled(
"no matches",
Style::default().add_modifier(Modifier::ITALIC | Modifier::DIM),
)))]));
} else {
let max_rows_from_area = area.height as usize;
let visible_rows = MAX_RESULTS
.min(rows_all.len())
.min(max_rows_from_area.max(1));
// Compute starting index based on scroll state and selection.
let mut start_idx = self.state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = self.state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else if visible_rows > 0 {
let bottom = start_idx + visible_rows - 1;
if sel > bottom {
start_idx = sel + 1 - visible_rows;
}
}
}
for (i, row) in rows_all
.into_iter()
.enumerate()
.skip(start_idx)
.take(visible_rows)
{
match row {
DisplayRow::Model {
name,
match_indices,
is_current,
} => {
// Highlight fuzzy indices when present.
let mut spans: Vec<Span> = Vec::with_capacity(name.len());
if let Some(idxs) = match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in name.chars().enumerate() {
let mut style = Style::default();
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
style = style.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(ch.to_string(), style));
}
} else {
spans.push(Span::raw(name.clone()));
}
let mut cell = Cell::from(Line::from(spans));
if Some(i) == self.state.selected_idx {
cell = cell.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
} else if is_current {
cell = cell.style(Style::default().fg(Color::Cyan));
}
rows.push(Row::new(vec![cell]));
}
}
}
}
let table = Table::new(rows, vec![Constraint::Percentage(100)])
.block(
Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().fg(Color::DarkGray)),
)
.widths([Constraint::Percentage(100)]);
table.render(area, buf);
render_rows(area, buf, &rows_all, &self.state, MAX_RESULTS);
}
}

View File

@@ -103,7 +103,10 @@ mod tests {
s.move_up_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(len - 1));
assert!(s.scroll_top <= s.selected_idx.unwrap());
match s.selected_idx {
Some(sel) => assert!(s.scroll_top <= sel),
None => panic!("expected Some(selected_idx) after wrap"),
}
// Move down wraps to start
s.move_down_wrap(len);

View File

@@ -0,0 +1,214 @@
use codex_common::fuzzy_match::fuzzy_match;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
/// One selectable item in a generic selection list.
#[derive(Clone)]
pub(crate) struct SelectionItem<T> {
pub value: T,
pub name: String,
pub description: Option<String>,
pub aliases: Vec<String>,
pub is_current: bool,
}
impl<T> SelectionItem<T> {
pub fn new(value: T, name: String) -> Self {
Self {
value,
name,
description: None,
aliases: Vec::new(),
is_current: false,
}
}
pub fn with_description(mut self, desc: Option<String>) -> Self {
self.description = desc;
self
}
pub fn with_aliases(mut self, aliases: Vec<String>) -> Self {
self.aliases = aliases;
self
}
pub fn mark_current(mut self, is_current: bool) -> Self {
self.is_current = is_current;
self
}
}
/// Generic selection list state and fuzzy filtering.
pub(crate) struct SelectionList<T> {
items: Vec<SelectionItem<T>>,
query: String,
pub state: ScrollState,
}
impl<T: Clone> SelectionList<T> {
pub fn new(items: Vec<SelectionItem<T>>) -> Self {
let mut this = Self {
items,
query: String::new(),
state: ScrollState::new(),
};
let visible_len = this.visible_rows().len();
this.state.clamp_selection(visible_len);
this.state.ensure_visible(visible_len, visible_len.min(8));
this
}
pub fn set_query(&mut self, query: &str) {
if self.query == query {
return;
}
self.query.clear();
self.query.push_str(query);
let visible_len = self.visible_rows().len();
if visible_len == 0 {
self.state.reset();
} else {
self.state.selected_idx = Some(0);
self.state.ensure_visible(visible_len, visible_len.min(8));
}
}
pub fn move_up(&mut self) {
let len = self.visible_rows().len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, len.min(8));
}
pub fn move_down(&mut self) {
let len = self.visible_rows().len();
self.state.move_down_wrap(len);
self.state.ensure_visible(len, len.min(8));
}
pub fn selected_value(&self) -> Option<T> {
let rows = self.visible_rows();
self.state
.selected_idx
.and_then(|idx| rows.get(idx))
.and_then(|r| r.1)
.cloned()
}
/// Visible rows paired with a reference to the underlying value (if any).
/// The GenericDisplayRow contains only presentation data; we pair it with
/// an Option<&T> so callers can map the selection back to a value.
pub fn visible_rows(&self) -> Vec<(GenericDisplayRow, Option<&T>)> {
let query = self.query.trim();
// Helper to convert an item to a GenericDisplayRow with optional match indices.
let to_row = |it: &SelectionItem<T>, match_indices: Option<Vec<usize>>| GenericDisplayRow {
name: it.name.clone(),
match_indices,
is_current: it.is_current,
description: it.description.clone(),
};
if query.is_empty() {
return self
.items
.iter()
.map(|it| (to_row(it, None), Some(&it.value)))
.collect();
}
// Fuzzy search across names and aliases; sort by score (smaller is better),
// then by name for a stable ordering. If the current item matches, include
// it with indices so the UI can highlight matches.
let mut out: Vec<(GenericDisplayRow, Option<&T>, i32, usize)> = Vec::new();
for it in self.items.iter() {
if let Some((indices, score)) = fuzzy_match(&it.name, query) {
out.push((
to_row(it, Some(indices)),
Some(&it.value),
score,
it.name.len(),
));
continue;
}
// Try aliases; keep the best score but do not show indices on the primary name.
let mut best_alias_score: Option<i32> = None;
for alias in it.aliases.iter() {
if let Some((_idx, score)) = fuzzy_match(alias, query) {
best_alias_score = Some(best_alias_score.map_or(score, |s| s.max(score)));
}
}
if let Some(score) = best_alias_score {
out.push((to_row(it, None), Some(&it.value), score, it.name.len()));
}
}
// Sort: lower score first (fuzzy_match returns smaller score = better),
// then by name asc, then by name length as a final tiebreaker.
out.sort_by(|a, b| {
a.2.cmp(&b.2)
.then_with(|| a.0.name.cmp(&b.0.name))
.then_with(|| a.3.cmp(&b.3))
});
out.into_iter()
.map(|(row, val, _score, _)| (row, val))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::SelectionItem;
use super::SelectionList;
#[test]
fn selection_list_query_and_navigation() {
// Build a small list with aliases similar to approvals popup.
let items = vec![
SelectionItem::new("a", "Auto".to_string()).with_aliases(vec![
"auto".into(),
"full-auto".into(),
"on-failure".into(),
]),
SelectionItem::new("p", "Prompt on Writes".to_string())
.with_aliases(vec!["untrusted".into(), "prompt-on-writes".into()]),
SelectionItem::new("d", "Deny all".to_string())
.with_aliases(vec!["never".into(), "deny-all".into()]),
];
let mut list = SelectionList::new(items);
// No query shows all, selection clamped to first row.
let rows = list.visible_rows();
assert_eq!(rows.len(), 3);
assert_eq!(list.selected_value(), Some("a"));
// Up wraps to the end.
list.move_up();
assert_eq!(list.selected_value(), Some("d"));
// Down wraps back to the start.
list.move_down();
assert_eq!(list.selected_value(), Some("a"));
// Query by name prefix prefers the tighter match.
list.set_query("auto");
let rows = list.visible_rows();
assert_eq!(rows.len(), 1);
assert_eq!(list.selected_value(), Some("a"));
// Query by alias works too.
list.set_query("deny-all");
let rows = list.visible_rows();
assert_eq!(rows.len(), 1);
assert_eq!(list.selected_value(), Some("d"));
// No matches clears selection.
list.set_query("not-a-match");
let rows = list.visible_rows();
assert_eq!(rows.len(), 0);
assert!(list.selected_value().is_none());
}
}

View File

@@ -0,0 +1,178 @@
use codex_core::protocol::AskForApproval;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use super::selection_list::SelectionItem;
use super::selection_list::SelectionList;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SelectionKind {
Model,
Approval,
}
#[derive(Clone)]
pub(crate) enum SelectionValue {
Model(String),
Approval(AskForApproval),
}
pub(crate) struct SelectionPopup {
kind: SelectionKind,
list: SelectionList<SelectionValue>,
}
const MAX_RESULTS: usize = 8;
impl SelectionPopup {
pub(crate) fn new_model(current_model: &str, options: Vec<String>) -> Self {
let mut items: Vec<SelectionItem<SelectionValue>> = Vec::new();
items.push(
SelectionItem::new(
SelectionValue::Model(current_model.to_string()),
current_model.to_string(),
)
.mark_current(true),
);
for m in options.into_iter().filter(|m| m != current_model) {
items.push(SelectionItem::new(SelectionValue::Model(m.clone()), m));
}
Self {
kind: SelectionKind::Model,
list: SelectionList::new(items),
}
}
pub(crate) fn new_approvals(current: AskForApproval, options: Vec<AskForApproval>) -> Self {
fn display_name(mode: AskForApproval) -> &'static str {
match mode {
AskForApproval::UnlessTrusted => "Prompt on Writes",
AskForApproval::OnFailure => "Auto",
AskForApproval::Never => "Deny all",
}
}
fn description_for(mode: AskForApproval) -> &'static str {
match mode {
AskForApproval::UnlessTrusted => {
"ask for approval for every write in the CWD and every sandbox breach"
}
AskForApproval::OnFailure => "only ask for commands that would breach the sandbox",
AskForApproval::Never => {
"deny all writes and commands that would breach the sandbox"
}
}
}
fn aliases_for(mode: AskForApproval) -> &'static [&'static str] {
match mode {
AskForApproval::UnlessTrusted => {
&["untrusted", "prompt-on-writes", "prompt on writes"]
}
AskForApproval::OnFailure => {
&["auto", "full-auto", "on-failure", "fullauto", "full"]
}
AskForApproval::Never => &["never", "deny-all", "deny all"],
}
}
let mut items: Vec<SelectionItem<SelectionValue>> = Vec::new();
items.push(
SelectionItem::new(
SelectionValue::Approval(current),
display_name(current).to_string(),
)
.with_description(Some(description_for(current).to_string()))
.with_aliases(aliases_for(current).iter().map(|s| s.to_string()).collect())
.mark_current(true),
);
for m in options.into_iter().filter(|m| *m != current) {
items.push(
SelectionItem::new(SelectionValue::Approval(m), display_name(m).to_string())
.with_description(Some(description_for(m).to_string()))
.with_aliases(aliases_for(m).iter().map(|s| s.to_string()).collect()),
);
}
Self {
kind: SelectionKind::Approval,
list: SelectionList::new(items),
}
}
pub(crate) fn kind(&self) -> SelectionKind {
self.kind
}
pub(crate) fn set_query(&mut self, query: &str) {
self.list.set_query(query);
}
pub(crate) fn move_up(&mut self) {
self.list.move_up();
}
pub(crate) fn move_down(&mut self) {
self.list.move_down();
}
pub(crate) fn calculate_required_height(&self) -> u16 {
self.list.visible_rows().len().clamp(1, MAX_RESULTS) as u16
}
pub(crate) fn selected_value(&self) -> Option<SelectionValue> {
self.list.selected_value()
}
fn visible_rows(&self) -> Vec<GenericDisplayRow> {
self.list
.visible_rows()
.into_iter()
.map(|(row, _)| row)
.collect()
}
}
impl WidgetRef for &SelectionPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let rows_all = self.visible_rows();
render_rows(area, buf, &rows_all, &self.list.state, MAX_RESULTS);
}
}
/// Parse a free-form token to an approval mode. Used by typed /approvals.
pub(crate) fn parse_approval_mode_token(s: &str) -> Option<AskForApproval> {
let t = s.trim().to_ascii_lowercase();
match t.as_str() {
"untrusted" | "prompt-on-writes" | "prompt on writes" => {
Some(AskForApproval::UnlessTrusted)
}
"on-failure" | "auto" | "full-auto" | "fullauto" | "full" => {
Some(AskForApproval::OnFailure)
}
"never" | "deny-all" | "deny all" => Some(AskForApproval::Never),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::parse_approval_mode_token as parse;
use codex_core::protocol::AskForApproval;
#[test]
fn parse_approval_mode_aliases() {
// OnFailure
for t in ["auto", "full-auto", "on-failure", "fullauto", "full"] {
assert_eq!(parse(t), Some(AskForApproval::OnFailure), "{t}");
}
// UnlessTrusted
for t in ["untrusted", "prompt-on-writes", "prompt on writes"] {
assert_eq!(parse(t), Some(AskForApproval::UnlessTrusted), "{t}");
}
// Never
for t in ["never", "deny-all", "deny all"] {
assert_eq!(parse(t), Some(AskForApproval::Never), "{t}");
}
// Unknown
assert_eq!(parse("unknown"), None);
// Whitespace and case-insensitivity
assert_eq!(parse(" FULL-AUTO "), Some(AskForApproval::OnFailure));
}
}

View File

@@ -0,0 +1,126 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Constraint;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use super::scroll_state::ScrollState;
/// A generic representation of a display row for selection popups.
pub(crate) struct GenericDisplayRow {
pub name: String,
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub is_current: bool,
pub description: Option<String>, // optional grey text after the name
}
impl GenericDisplayRow {}
/// Render a list of rows using the provided ScrollState, with shared styling
/// and behavior for selection popups.
pub(crate) fn render_rows(
area: Rect,
buf: &mut Buffer,
rows_all: &[GenericDisplayRow],
state: &ScrollState,
max_results: usize,
) {
let mut rows: Vec<Row> = Vec::new();
if rows_all.is_empty() {
rows.push(Row::new(vec![Cell::from(Line::from(Span::styled(
"no matches",
Style::default().add_modifier(Modifier::ITALIC | Modifier::DIM),
)))]));
} else {
let max_rows_from_area = area.height as usize;
let visible_rows = max_results
.min(rows_all.len())
.min(max_rows_from_area.max(1));
// Compute starting index based on scroll state and selection.
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
if sel < start_idx {
start_idx = sel;
} else if visible_rows > 0 {
let bottom = start_idx + visible_rows - 1;
if sel > bottom {
start_idx = sel + 1 - visible_rows;
}
}
}
for (i, row) in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_rows)
{
let GenericDisplayRow {
name,
match_indices,
is_current,
description,
} = row;
// Highlight fuzzy indices when present.
let mut spans: Vec<Span> = Vec::with_capacity(name.len());
if let Some(idxs) = match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in name.chars().enumerate() {
let mut style = Style::default();
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
style = style.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(ch.to_string(), style));
}
} else {
spans.push(Span::raw(name.clone()));
}
if let Some(desc) = description.as_ref() {
spans.push(Span::raw(" "));
spans.push(Span::styled(
desc.clone(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
));
}
let mut cell = Cell::from(Line::from(spans));
if Some(i) == state.selected_idx {
cell = cell.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
);
} else if *is_current {
cell = cell.style(Style::default().fg(Color::Cyan));
}
rows.push(Row::new(vec![cell]));
}
}
let table = Table::new(rows, vec![Constraint::Percentage(100)])
.block(
Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().fg(Color::DarkGray)),
)
.widths([Constraint::Percentage(100)]);
table.render(area, buf);
}

View File

@@ -13,6 +13,7 @@ use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
use codex_core::protocol::AgentReasoningEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::ErrorEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
@@ -573,6 +574,40 @@ impl ChatWidget<'_> {
self.submit_op(op);
self.request_redraw();
}
/// Open the approval selection view in the bottom pane.
pub(crate) fn show_approval_selector(&mut self) {
let current = self.config.approval_policy;
let options = vec![
AskForApproval::UnlessTrusted,
AskForApproval::OnFailure,
AskForApproval::Never,
];
self.bottom_pane.show_approval_selector(current, options);
}
/// Update the approval policy and reconfigure the running Codex session.
pub(crate) fn update_approval_policy_and_reconfigure(&mut self, mode: AskForApproval) {
let changed = self.config.approval_policy != mode;
self.config.approval_policy = mode;
if changed {
let mode_str = match mode {
AskForApproval::UnlessTrusted => "Prompt on Writes",
AskForApproval::OnFailure => "Auto",
AskForApproval::Never => "Deny all",
};
self.add_to_history(HistoryCell::new_background_event(format!(
"Set approval mode to {mode_str}."
)));
}
let op = self
.config
.to_configure_session_op(None, self.config.user_instructions.clone());
self.submit_op(op);
self.request_redraw();
}
}
impl WidgetRef for &ChatWidget<'_> {

View File

@@ -16,6 +16,7 @@ pub enum SlashCommand {
Compact,
Diff,
Model,
Approvals,
Quit,
#[cfg(debug_assertions)]
TestApproval,
@@ -29,6 +30,7 @@ impl SlashCommand {
SlashCommand::Compact => "Compact the chat history.",
SlashCommand::Quit => "Exit the application.",
SlashCommand::Model => "Select the model to use.",
SlashCommand::Approvals => "Select the approval mode.",
SlashCommand::Diff => {
"Show git diff of the working directory (including untracked files)"
}