mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
add approvals slash command, models option
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
110
codex-rs/tui/src/bottom_pane/approval_selection_popup.rs
Normal file
110
codex-rs/tui/src/bottom_pane/approval_selection_popup.rs
Normal 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 free‑form 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
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
214
codex-rs/tui/src/bottom_pane/selection_list.rs
Normal file
214
codex-rs/tui/src/bottom_pane/selection_list.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
178
codex-rs/tui/src/bottom_pane/selection_popup.rs
Normal file
178
codex-rs/tui/src/bottom_pane/selection_popup.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
126
codex-rs/tui/src/bottom_pane/selection_popup_common.rs
Normal file
126
codex-rs/tui/src/bottom_pane/selection_popup_common.rs
Normal 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);
|
||||
}
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user