This commit is contained in:
easong-openai
2025-08-04 01:02:54 -07:00
parent 20ebd6b2ce
commit 16e1a5b45b
7 changed files with 253 additions and 467 deletions

View File

@@ -510,8 +510,13 @@ impl App<'_> {
}
(SlashCommand::Model, Some(args)) => self.handle_model_command(args),
(SlashCommand::Approvals, Some(args)) => self.handle_approvals_command(args),
// Disallow `/model` and `/approvals` without args: no action.
(SlashCommand::Model, None) | (SlashCommand::Approvals, None) => {}
// With no args, open the corresponding selector popups.
(SlashCommand::Model, None) => {
self.app_event_tx.send(AppEvent::OpenModelSelector)
}
(SlashCommand::Approvals, None) => {
self.app_event_tx.send(AppEvent::OpenExecutionSelector)
}
},
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);

View File

@@ -79,30 +79,6 @@ impl ChatComposer<'_> {
.unwrap_or("")
}
#[inline]
fn slash_token_from_first_line(first_line: &str) -> Option<&str> {
if !first_line.starts_with('/') {
return None;
}
let stripped = first_line.strip_prefix('/').unwrap_or("");
let token = stripped.trim_start();
Some(token.split_whitespace().next().unwrap_or(""))
}
#[inline]
fn emit_unrecognized_slash_command(&mut self, cmd_token: &str) {
let attempted = if cmd_token.is_empty() {
"/".to_string()
} else {
format!("/{cmd_token}")
};
let msg = format!("{attempted} not a recognized command");
self.app_event_tx
.send(AppEvent::InsertHistory(vec![Line::from(msg)]));
self.dismissed.slash = Some(cmd_token.to_string());
self.active_popup = ActivePopup::None;
}
#[inline]
fn sync_popups(&mut self) {
self.sync_command_popup();
@@ -303,16 +279,11 @@ impl ChatComposer<'_> {
// If the composer currently contains a `/model` command, initialize the
// popup's query from its arguments. Otherwise, leave the popup visible
// with an empty query.
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::Model.command() {
let first_line_owned = self.first_line().to_string();
if let ParsedSlash::Command { cmd, args } = parse_slash_line(&first_line_owned) {
if cmd == SlashCommand::Model {
if let ActivePopup::Selection(popup) = &mut self.active_popup {
popup.set_query(&args);
popup.set_query(args);
}
}
}
@@ -336,16 +307,11 @@ impl ChatComposer<'_> {
}
}
// 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() {
let first_line_owned = self.first_line().to_string();
if let ParsedSlash::Command { cmd, args } = parse_slash_line(&first_line_owned) {
if cmd == SlashCommand::Approvals {
if let ActivePopup::Selection(popup) = &mut self.active_popup {
popup.set_query(&args);
popup.set_query(args);
}
}
}
@@ -398,8 +364,14 @@ impl ChatComposer<'_> {
(InputResult::None, true)
}
Input { key: Key::Esc, .. } => {
if let Some(cmd_token) = Self::slash_token_from_first_line(&first_line_owned) {
self.dismissed.slash = Some(cmd_token.to_string());
// Remember the dismissed token to avoid immediate reopen until input changes.
let token = match parse_slash_line(&first_line_owned) {
ParsedSlash::Command { cmd, .. } => Some(cmd.command().to_string()),
ParsedSlash::Incomplete { token } => Some(token.to_string()),
ParsedSlash::None => None,
};
if let Some(tok) = token {
self.dismissed.slash = Some(tok);
}
self.active_popup = ActivePopup::None;
(InputResult::None, true)
@@ -425,34 +397,23 @@ impl ChatComposer<'_> {
ctrl: false,
} => {
if let Some(cmd) = popup.selected_command() {
// Extract arguments after the command from the first line.
let first_line = self
.textarea
.lines()
.first()
.map(|s| s.as_str())
.unwrap_or("");
let args = if let Some((_, args)) =
Self::parse_slash_command_and_args_from_line(first_line)
{
args
} else {
String::new()
// Extract arguments after the command from the first line using the shared parser.
let args_opt = match parse_slash_line(&first_line_owned) {
ParsedSlash::Command {
cmd: parsed_cmd,
args,
} if parsed_cmd == *cmd => {
let a = args.trim().to_string();
if a.is_empty() { None } else { Some(a) }
}
_ => None,
};
// 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::OpenExecutionSelector);
} else {
// Send command + args to the app layer.
self.app_event_tx.send(AppEvent::DispatchCommand {
cmd: *cmd,
args: Some(args),
});
}
// Send command + args (if any) to the app layer.
self.app_event_tx.send(AppEvent::DispatchCommand {
cmd: *cmd,
args: args_opt,
});
// Clear textarea so no residual text remains
self.textarea.select_all();
@@ -460,11 +421,29 @@ impl ChatComposer<'_> {
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
if let Some(cmd_token) = Self::slash_token_from_first_line(&first_line_owned) {
self.emit_unrecognized_slash_command(cmd_token);
return (InputResult::None, true);
// No valid selection treat as invalid command: dismiss popup and surface error.
let invalid_token = match parse_slash_line(&first_line_owned) {
ParsedSlash::Command { cmd, .. } => cmd.command().to_string(),
ParsedSlash::Incomplete { token } => token.to_string(),
ParsedSlash::None => String::new(),
};
// Prevent immediate reopen for the same token.
self.dismissed.slash = Some(invalid_token.clone());
self.active_popup = ActivePopup::None;
// Emit an error entry into history so the user understands what happened.
{
use crate::history_cell::HistoryCell;
let message = if invalid_token.is_empty() {
"Invalid command".to_string()
} else {
format!("Invalid command: /{invalid_token}")
};
let lines = HistoryCell::new_error_event(message).plain_lines();
self.app_event_tx.send(AppEvent::InsertHistory(lines));
}
(InputResult::None, false)
(InputResult::None, true)
}
input => self.handle_input_basic(input),
}
@@ -472,6 +451,7 @@ impl ChatComposer<'_> {
/// Handle key events when file search popup is visible.
fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let first_line_owned = self.first_line().to_string();
let ActivePopup::File(popup) = &mut self.active_popup else {
unreachable!();
};
@@ -518,6 +498,7 @@ impl ChatComposer<'_> {
&mut self,
key_event: KeyEvent,
) -> (InputResult, bool) {
let first_line_owned = self.first_line().to_string();
let ActivePopup::Selection(popup) = &mut self.active_popup else {
unreachable!();
};
@@ -560,20 +541,11 @@ impl ChatComposer<'_> {
return (InputResult::None, true);
}
// No selection in the list: attempt to parse typed arguments for the appropriate kind.
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 let ParsedSlash::Command { cmd, args } = parse_slash_line(&first_line_owned) {
let args = args.trim().to_string();
if !args.is_empty() {
match popup.kind() {
SelectionKind::Model if cmd_token == SlashCommand::Model.command() => {
SelectionKind::Model if cmd == SlashCommand::Model => {
self.app_event_tx.send(AppEvent::DispatchCommand {
cmd: SlashCommand::Model,
args: Some(args),
@@ -584,9 +556,7 @@ impl ChatComposer<'_> {
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
SelectionKind::Execution
if cmd_token == SlashCommand::Approvals.command() =>
{
SelectionKind::Execution if cmd == SlashCommand::Approvals => {
if let Some((approval, sandbox)) = parse_execution_mode_token(&args)
{
self.app_event_tx
@@ -835,51 +805,16 @@ impl ChatComposer<'_> {
if !input_starts_with_slash {
self.dismissed.slash = None;
}
let current_cmd_token: Option<&str> = Self::slash_token_from_first_line(&first_line);
// Special handling: if the user typed `/model ` (with a space), open the model selector
// and do not show the slash-command popup.
let should_open_model_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::Model.command() {
let rest = &token[cmd_token.len()..];
// Show model popup as soon as a whitespace after the command is present.
rest.chars().next().is_some_and(|c| c.is_whitespace())
} else {
false
}
} else {
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
let current_cmd_token: Option<String> = match parse_slash_line(&first_line) {
ParsedSlash::Command { cmd, .. } => Some(cmd.command().to_string()),
ParsedSlash::Incomplete { token } => Some(token.to_string()),
ParsedSlash::None => None,
};
match &mut self.active_popup {
ActivePopup::Command(popup) => {
if input_starts_with_slash {
if should_open_model_selector {
// 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::OpenExecutionSelector);
} else {
popup.on_composer_text_change(first_line.clone());
}
popup.on_composer_text_change(first_line.clone());
} else {
self.active_popup = ActivePopup::None;
self.dismissed.slash = None;
@@ -887,22 +822,19 @@ impl ChatComposer<'_> {
}
_ => {
if input_starts_with_slash {
if should_open_model_selector {
// 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::OpenExecutionSelector);
} else {
// Avoid immediate reopen of the slash popup if it was just dismissed for
// this exact command token.
if self.dismissed.slash.as_deref() == current_cmd_token {
return;
}
let mut command_popup = CommandPopup::new();
command_popup.on_composer_text_change(first_line);
self.active_popup = ActivePopup::Command(command_popup);
// Avoid immediate reopen of the slash popup if it was just dismissed for
// this exact command token.
if self
.dismissed
.slash
.as_ref()
.is_some_and(|d| Some(d) == current_cmd_token.as_ref())
{
return;
}
let mut command_popup = CommandPopup::new();
command_popup.on_composer_text_change(first_line);
self.active_popup = ActivePopup::Command(command_popup);
}
}
}
@@ -945,50 +877,35 @@ impl ChatComposer<'_> {
}
/// Synchronize the selection popup filter with the current composer text.
///
/// When a selection popup is open, we want typing to filter the visible
/// options. If the user is typing a slash command (e.g. `/model o3` or
/// `/approvals auto`), we use only the arguments after the command token
/// as the filter. Otherwise, we treat the entire first line as the filter
/// so that typing freeform text narrows the list as well.
fn sync_selection_popup(&mut self) {
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) {
match &mut self.active_popup {
ActivePopup::Selection(popup) if popup.kind() == SelectionKind::Model => {
if cmd_token == SlashCommand::Model.command() {
popup.set_query(&args);
}
let first_line_owned = self.first_line().to_string();
match (&mut self.active_popup, parse_slash_line(&first_line_owned)) {
(ActivePopup::Selection(popup), ParsedSlash::Command { cmd, args }) => match popup
.kind()
{
SelectionKind::Model if cmd == SlashCommand::Model => popup.set_query(args),
SelectionKind::Execution if cmd == SlashCommand::Approvals => popup.set_query(args),
_ => {
// Command present but not relevant to the open selector
// fall back to using the freeform text as the query.
popup.set_query(first_line_owned.trim());
}
ActivePopup::Selection(popup) if popup.kind() == SelectionKind::Execution => {
if cmd_token == SlashCommand::Approvals.command() {
popup.set_query(&args);
}
}
_ => {}
},
(ActivePopup::Selection(popup), _no_slash_cmd) => {
// No slash command present use whatever is typed as the query.
popup.set_query(first_line_owned.trim());
}
_ => {}
}
}
/// Parse a leading "/command" via the centralized slash parser, returning
/// (command_token, args_trimmed_left) for compatibility with existing code.
/// Returns None if the line does not start with a slash or the command token is empty.
fn parse_slash_command_and_args_from_line(line: &str) -> Option<(String, String)> {
match parse_slash_line(line) {
ParsedSlash::Command { cmd, args } => {
Some((cmd.command().to_string(), args.to_string()))
}
ParsedSlash::Incomplete { token } if !token.is_empty() => {
// Compute rest of line after token for completeness.
// We mimic the previous logic: take the substring after the token in the trimmed view.
let stripped = line.strip_prefix('/')?;
let token_with_ws = stripped.trim_start();
let rest = &token_with_ws[token.len()..];
Some((token.to_string(), rest.trim_start().to_string()))
}
_ => None,
}
}
// removed duplicate slash parsing helpers; use parse_slash_line directly
fn update_border(&mut self, has_focus: bool) {
let border_style = if has_focus {
@@ -1369,52 +1286,6 @@ mod tests {
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
}
#[test]
fn enter_with_unrecognized_slash_command_closes_popup_and_emits_error() {
use crate::app_event::AppEvent;
use crate::bottom_pane::chat_composer::ActivePopup;
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);
composer.handle_paste("/ notacommand args".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::None));
let mut saw_error = false;
loop {
match rx.try_recv() {
Ok(AppEvent::InsertHistory(lines)) => {
let text = lines
.into_iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join("\n");
if text.contains("/notacommand not a recognized command") {
saw_error = true;
break;
}
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert!(
saw_error,
"expected InsertHistory error for unrecognized command"
);
}
#[test]
fn esc_dismiss_then_delete_and_retype_slash_reopens_popup() {
use crate::bottom_pane::chat_composer::ActivePopup;
@@ -1439,8 +1310,38 @@ mod tests {
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
}
// removed tests tied to auto-opening selectors and composer-owned error messages
#[test]
fn esc_dismiss_on_model_then_space_requests_model_selector() {
fn slash_popup_filters_as_user_types() {
use crate::bottom_pane::chat_composer::ActivePopup;
use crate::slash_command::SlashCommand;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Open the slash popup.
composer.handle_paste("/".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
// Type 'mo' and ensure the top selection corresponds to /model.
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
if let ActivePopup::Command(popup) = &composer.active_popup {
let selected = popup.selected_command();
assert_eq!(selected, Some(SlashCommand::Model).as_ref());
} else {
panic!("expected Command popup");
}
}
#[test]
fn enter_with_invalid_slash_token_shows_error_and_closes_popup() {
use crate::app_event::AppEvent;
use crate::bottom_pane::chat_composer::ActivePopup;
use crossterm::event::KeyCode;
@@ -1452,72 +1353,37 @@ mod tests {
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Type '/model'
composer.handle_paste("/model".to_string());
// Ensure command popup present
composer.handle_paste("/zzz".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
// Dismiss with Esc, which records dismissed_slash_token = "model"
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let (result, _redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(result, InputResult::None));
// Popup should be closed.
assert!(matches!(composer.active_popup, ActivePopup::None));
// Type a space to make it '/model '
composer.handle_paste(" ".to_string());
// We should emit OpenModelSelector even though the slash token was dismissed.
let mut saw_open = false;
// We should receive an InsertHistory with an error message.
let mut saw_error = false;
loop {
match rx.try_recv() {
Ok(AppEvent::OpenModelSelector) => {
saw_open = true;
break;
Ok(AppEvent::InsertHistory(lines)) => {
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect::<Vec<_>>()
.join("");
if joined.to_lowercase().contains("invalid command") {
saw_error = true;
break;
}
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert!(
saw_open,
"expected OpenModelSelector event after '/model ' characters"
);
}
#[test]
fn selecting_model_in_slash_menu_opens_model_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 model command in slash popup
composer.handle_paste("/mo".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 OpenModelSelector
let mut saw_open = false;
loop {
match rx.try_recv() {
Ok(AppEvent::OpenModelSelector) => {
saw_open = true;
break;
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert!(
saw_open,
"pressing Enter on /model should open model selector"
);
assert!(saw_error, "expected an error InsertHistory entry");
}
#[test]
@@ -1589,6 +1455,51 @@ mod tests {
assert!(matches!(composer.active_popup, ActivePopup::Selection(_)));
}
#[test]
fn model_selector_filters_with_free_text_typing() {
use crate::app_event::AppEvent;
use crate::bottom_pane::chat_composer::ActivePopup;
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);
let options = vec![
"codex-mini-latest".to_string(),
"o3".to_string(),
"gpt-4o".to_string(),
];
composer.open_model_selector("o3", options);
assert!(matches!(composer.active_popup, ActivePopup::Selection(_)));
// Type a freeform query (without leading /model) and ensure it filters.
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE));
// Press Enter to select the (filtered) current row.
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// We should receive a SelectModel for the filtered option.
let mut selected: Option<String> = None;
loop {
match rx.try_recv() {
Ok(AppEvent::SelectModel(m)) => {
selected = Some(m);
break;
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert_eq!(selected.as_deref(), Some("gpt-4o"));
}
#[test]
fn test_multiple_pastes_submission() {
use crossterm::event::KeyCode;
@@ -1820,42 +1731,7 @@ 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 OpenExecutionSelector
let mut saw_open = false;
loop {
match rx.try_recv() {
Ok(AppEvent::OpenExecutionSelector) => {
saw_open = true;
break;
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert!(
saw_open,
"pressing Enter on /approvals should open selector"
);
}
// removed test tied to composer opening approvals selector
#[test]
fn enter_on_approvals_selector_selects_current_row() {

View File

@@ -1,22 +1,12 @@
use codex_file_search::FileMatch;
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::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
/// Visual state for the file-search popup.
pub(crate) struct FileSearchPopup {
@@ -29,8 +19,8 @@ pub(crate) struct FileSearchPopup {
waiting: bool,
/// Cached matches; paths relative to the search dir.
matches: Vec<FileMatch>,
/// Currently selected index inside `matches` (if any).
selected_idx: Option<usize>,
/// Shared selection/scroll state.
state: ScrollState,
}
impl FileSearchPopup {
@@ -40,7 +30,7 @@ impl FileSearchPopup {
pending_query: String::new(),
waiting: true,
matches: Vec::new(),
selected_idx: None,
state: ScrollState::new(),
}
}
@@ -60,7 +50,7 @@ impl FileSearchPopup {
if !keep_existing {
self.matches.clear();
self.selected_idx = None;
self.state.reset();
}
}
@@ -74,35 +64,28 @@ impl FileSearchPopup {
self.display_query = query.to_string();
self.matches = matches;
self.waiting = false;
self.selected_idx = if self.matches.is_empty() {
None
} else {
Some(0)
};
let len = self.matches.len();
self.state.clamp_selection(len);
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
/// Move selection cursor up.
pub(crate) fn move_up(&mut self) {
if let Some(idx) = self.selected_idx {
if idx > 0 {
self.selected_idx = Some(idx - 1);
}
}
let len = self.matches.len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
/// Move selection cursor down.
pub(crate) fn move_down(&mut self) {
if let Some(idx) = self.selected_idx {
if idx + 1 < self.matches.len() {
self.selected_idx = Some(idx + 1);
}
} else if !self.matches.is_empty() {
self.selected_idx = Some(0);
}
let len = self.matches.len();
self.state.move_down_wrap(len);
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
pub(crate) fn selected_match(&self) -> Option<&str> {
self.selected_idx
self.state
.selected_idx
.and_then(|idx| self.matches.get(idx))
.map(|file_match| file_match.path.as_str())
}
@@ -120,63 +103,29 @@ impl FileSearchPopup {
impl WidgetRef for &FileSearchPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Prepare rows.
let rows: Vec<Row> = if self.matches.is_empty() {
vec![Row::new(vec![
Cell::from(if self.waiting {
"(searching …)"
} else {
"no matches"
})
.style(Style::new().add_modifier(Modifier::ITALIC | Modifier::DIM)),
])]
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
Vec::new()
} else {
self.matches
.iter()
.take(MAX_POPUP_ROWS)
.enumerate()
.map(|(i, file_match)| {
let FileMatch { path, indices, .. } = file_match;
let path = path.as_str();
let indices_opt = indices.as_ref();
// Build spans with bold on matching indices when provided by the searcher.
let mut idx_iter = indices_opt.map(|v| v.iter().peekable());
let mut spans: Vec<Span> = Vec::with_capacity(path.len());
for (char_idx, ch) in path.chars().enumerate() {
let mut style = Style::default();
if let Some(iter) = idx_iter.as_mut() {
if iter.peek().is_some_and(|next| **next == char_idx as u32) {
iter.next();
style = style.add_modifier(Modifier::BOLD);
}
}
spans.push(Span::styled(ch.to_string(), style));
}
// Create cell from the spans.
let mut cell = Cell::from(Line::from(spans));
// If selected, also paint yellow.
if Some(i) == self.selected_idx {
cell = cell.style(Style::default().fg(Color::Yellow));
}
Row::new(vec![cell])
.map(|m| GenericDisplayRow {
name: m.path.clone(),
match_indices: m
.indices
.as_ref()
.map(|v| v.iter().map(|&i| i as usize).collect()),
is_current: false,
description: None,
})
.collect()
};
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);
if self.waiting && rows_all.is_empty() {
// Render a minimal waiting stub using the shared renderer (no rows -> "no matches").
render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS);
} else {
render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
}
}
}

View File

@@ -134,37 +134,9 @@ impl WidgetRef for &SelectionPopup {
#[cfg(test)]
mod tests {
use crate::command_utils::parse_execution_mode_token as parse;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
#[test]
fn parse_approval_mode_aliases() {
// Only accept the three canonical tokens
assert!(matches!(
parse("auto"),
Some((
AskForApproval::OnFailure,
SandboxPolicy::WorkspaceWrite { .. }
))
));
assert_eq!(
parse("untrusted"),
Some((AskForApproval::OnFailure, SandboxPolicy::ReadOnly))
);
assert_eq!(
parse("read-only"),
Some((AskForApproval::Never, SandboxPolicy::ReadOnly))
);
// Unknown and case/whitespace handling
assert_eq!(parse("unknown"), None);
assert!(parse(" AUTO ").is_some());
assert_eq!(
parse("full-yolo"),
Some((AskForApproval::Never, SandboxPolicy::DangerFullAccess))
);
}
#[test]
fn execution_selector_includes_full_yolo() {
// Set a benign current mode; we only care about rows.
@@ -179,8 +151,8 @@ mod tests {
let rows = popup.visible_rows();
let labels: Vec<String> = rows.into_iter().map(|r| r.name).collect();
assert!(
labels.iter().any(|l| l.contains("Full yolo")),
"selector should include 'Full yolo'"
labels.iter().any(|l| l.contains("Danger")),
"selector should include 'Danger'"
);
}
}

View File

@@ -6,6 +6,7 @@ use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
// use ratatui::text::Text; // removed as we reverted multi-line cell rendering
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
@@ -124,3 +125,5 @@ pub(crate) fn render_rows(
table.render(area, buf);
}
// (wrapping test removed; keeping rendering simple for now)

View File

@@ -22,38 +22,23 @@ impl ExecutionPreset {
ExecutionPreset::ReadOnly => "Read only",
ExecutionPreset::Untrusted => "Untrusted",
ExecutionPreset::Auto => "Auto",
ExecutionPreset::FullYolo => "Full yolo",
ExecutionPreset::FullYolo => "Danger",
}
}
pub fn description(self) -> &'static str {
match self {
ExecutionPreset::ReadOnly => {
"never prompt; read-only filesystem (flags: --ask-for-approval never --sandbox read-only)"
}
ExecutionPreset::Untrusted => {
"ask to retry outside sandbox only on sandbox breach; read-only (flags: --ask-for-approval on-failure --sandbox read-only)"
}
ExecutionPreset::ReadOnly => "read only filesystem, never prompt for approval",
ExecutionPreset::Untrusted => "user confirms writes and commands outside sandbox",
ExecutionPreset::Auto => {
"auto in workspace sandbox; ask to retry outside sandbox on breach (flags: --ask-for-approval on-failure --sandbox workspace-write)"
"auto approve writes in the workspace; ask to run outside sandbox"
}
ExecutionPreset::FullYolo => {
"DANGEROUS: disables sandbox and approvals; the agent can run any commands with full system access (flags: --dangerously-bypass-approvals-and-sandbox)"
"disables sandbox and approvals; the agent can run any commands"
}
}
}
/// Canonical CLI flags string for the preset.
#[allow(dead_code)]
pub fn cli_flags(self) -> &'static str {
match self {
ExecutionPreset::ReadOnly => "--ask-for-approval never --sandbox read-only",
ExecutionPreset::Untrusted => "--ask-for-approval on-failure --sandbox read-only",
ExecutionPreset::Auto => "--ask-for-approval on-failure --sandbox workspace-write",
ExecutionPreset::FullYolo => "--dangerously-bypass-approvals-and-sandbox",
}
}
/// Mapping from preset to policies.
pub fn to_policies(self) -> (AskForApproval, SandboxPolicy) {
match self {
@@ -140,16 +125,7 @@ pub fn execution_mode_label(approval: AskForApproval, sandbox: &SandboxPolicy) -
.unwrap_or("Custom")
}
/// Describe the current execution preset including CLI flag equivalents.
#[allow(dead_code)]
pub fn execution_mode_description(
approval: AskForApproval,
sandbox: &SandboxPolicy,
) -> &'static str {
ExecutionPreset::from_policies(approval, sandbox)
.map(|p| p.description())
.unwrap_or("custom combination")
}
// removed unused execution_mode_description and cli_flags helpers
/// Parse a free-form token to an execution preset (approval+sandbox).
pub fn parse_execution_mode_token(s: &str) -> Option<(AskForApproval, SandboxPolicy)> {
@@ -230,4 +206,9 @@ mod tests {
assert_eq!(ExecutionPreset::parse_token(token), Some(p));
}
}
#[test]
fn full_yolo_label_is_danger() {
assert_eq!(ExecutionPreset::FullYolo.label(), "Danger");
}
}

View File

@@ -25,7 +25,7 @@ use ratatui::widgets::Wrap;
const DANGER_TEXT: &str = "You're about to disable both approvals and sandboxing.\n\
This gives the agent full, unrestricted access to your system.\n\
\n\
This can seriously damage your computer. Only proceed if you fully understand the risks.";
The agent can and will do stupid things as your user. Only proceed if you fully understand the risks.";
pub(crate) enum DangerWarningOutcome {
Continue,