mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
as is
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 free‑form 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 free‑form 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 free‑form 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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user