mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:23:33 +00:00
feat(approval): add semantic prefix matching
This commit is contained in:
+148
-6
@@ -38,10 +38,12 @@ use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
use crate::CodexAuth;
|
||||
use crate::agent_tool::AgentStatusUpdatePayload;
|
||||
use crate::protocol::ApprovedCommandMatchKind;
|
||||
use crate::protocol::WebSearchBeginEvent;
|
||||
use crate::protocol::WebSearchCompleteEvent;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use shlex::split as shlex_split;
|
||||
|
||||
pub mod compact;
|
||||
use self::compact::build_compacted_history;
|
||||
@@ -627,10 +629,89 @@ impl Codex {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable state of the agent
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ApprovedCommandPattern {
|
||||
argv: Vec<String>,
|
||||
kind: ApprovedCommandMatchKind,
|
||||
semantic_prefix: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ApprovedCommandPattern {
|
||||
pub(crate) fn new(
|
||||
argv: Vec<String>,
|
||||
kind: ApprovedCommandMatchKind,
|
||||
semantic_prefix: Option<Vec<String>>,
|
||||
) -> Self {
|
||||
let semantic_prefix = if matches!(kind, ApprovedCommandMatchKind::Prefix) {
|
||||
semantic_prefix.or_else(|| Some(argv.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Self {
|
||||
argv,
|
||||
kind,
|
||||
semantic_prefix,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn matches(&self, command: &[String]) -> bool {
|
||||
match self.kind {
|
||||
ApprovedCommandMatchKind::Exact => command == self.argv.as_slice(),
|
||||
ApprovedCommandMatchKind::Prefix => {
|
||||
if command.starts_with(&self.argv) {
|
||||
return true;
|
||||
}
|
||||
if let (Some(pattern), Some(candidate)) = (
|
||||
self.semantic_prefix.as_ref(),
|
||||
semantic_tokens(command),
|
||||
) {
|
||||
return candidate.starts_with(pattern);
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn argv(&self) -> &[String] { &self.argv }
|
||||
|
||||
pub fn kind(&self) -> ApprovedCommandMatchKind { self.kind }
|
||||
}
|
||||
|
||||
fn semantic_tokens(command: &[String]) -> Option<Vec<String>> {
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(tokens) = shell_script_tokens(command) {
|
||||
return Some(tokens);
|
||||
}
|
||||
Some(command.to_vec())
|
||||
}
|
||||
|
||||
fn shell_script_tokens(command: &[String]) -> Option<Vec<String>> {
|
||||
if command.len() == 3 && is_shell_wrapper(&command[0], &command[1]) {
|
||||
if let Some(tokens) = shlex_split(&command[2]) {
|
||||
return Some(tokens);
|
||||
}
|
||||
return Some(vec![command[2].clone()]);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_shell_wrapper(shell: &str, flag: &str) -> bool {
|
||||
let file_name = Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(shell)
|
||||
.to_ascii_lowercase();
|
||||
matches!(
|
||||
file_name.as_str(),
|
||||
"bash" | "sh" | "zsh" | "ksh" | "fish" | "dash"
|
||||
) && matches!(flag, "-lc" | "-c")
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
approved_commands: HashSet<Vec<String>>,
|
||||
approved_commands: HashSet<ApprovedCommandPattern>,
|
||||
current_task: Option<AgentTask>,
|
||||
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||||
pending_input: Vec<ResponseInputItem>,
|
||||
@@ -1070,9 +1151,9 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_approved_command(&self, cmd: Vec<String>) {
|
||||
pub fn add_approved_command(&self, pattern: ApprovedCommandPattern) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.approved_commands.insert(cmd);
|
||||
state.approved_commands.insert(pattern);
|
||||
}
|
||||
|
||||
/// Records items to both the rollout and the chat completions/ZDR
|
||||
@@ -2087,6 +2168,14 @@ async fn submission_loop(
|
||||
last_screenshot_info: Mutex::new(None),
|
||||
confirm_guard: ConfirmGuardRuntime::from_config(&config.confirm_guard),
|
||||
}));
|
||||
if let Some(sess_arc) = &sess {
|
||||
if !config.always_allow_commands.is_empty() {
|
||||
let mut st = sess_arc.state.lock().unwrap();
|
||||
for pattern in &config.always_allow_commands {
|
||||
st.approved_commands.insert(pattern.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut replay_history_items: Option<Vec<ResponseItem>> = None;
|
||||
|
||||
|
||||
@@ -2229,6 +2318,24 @@ async fn submission_loop(
|
||||
other => sess.notify_approval(&id, other),
|
||||
}
|
||||
}
|
||||
Op::RegisterApprovedCommand {
|
||||
command,
|
||||
match_kind,
|
||||
semantic_prefix,
|
||||
} => {
|
||||
if command.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(sess) = sess.as_ref() {
|
||||
sess.add_approved_command(ApprovedCommandPattern::new(
|
||||
command,
|
||||
match_kind,
|
||||
semantic_prefix,
|
||||
));
|
||||
} else {
|
||||
send_no_session_event(sub.id).await;
|
||||
}
|
||||
}
|
||||
Op::PatchApproval { id, decision } => {
|
||||
let sess = match sess.as_ref() {
|
||||
Some(sess) => sess,
|
||||
@@ -5372,6 +5479,31 @@ async fn handle_container_exec_with_params(
|
||||
None
|
||||
}
|
||||
|
||||
fn strip_leading_confirm_prefix(argv: &mut Vec<String>) -> bool {
|
||||
if argv.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let first = argv[0].trim().to_string();
|
||||
for prefix in ["confirm:", "CONFIRM:"] {
|
||||
if first == prefix {
|
||||
argv.remove(0);
|
||||
return true;
|
||||
}
|
||||
if let Some(rest) = first.strip_prefix(prefix) {
|
||||
let trimmed = rest.trim_start();
|
||||
if trimmed.is_empty() {
|
||||
argv.remove(0);
|
||||
} else {
|
||||
argv[0] = trimmed.to_string();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn guidance_for_sensitive_git(kind: SensitiveGitKind, original_label: &str, original_value: &str, suggested: &str) -> String {
|
||||
match kind {
|
||||
SensitiveGitKind::BranchChange => format!(
|
||||
@@ -5661,6 +5793,8 @@ async fn handle_container_exec_with_params(
|
||||
}
|
||||
}
|
||||
|
||||
strip_leading_confirm_prefix(&mut params.command);
|
||||
|
||||
// If no shell wrapper, perform a lightweight argv inspection for sensitive git commands.
|
||||
if extract_shell_script_from_wrapper(¶ms.command).is_none() {
|
||||
let joined = params.command.join(" ");
|
||||
@@ -5903,7 +6037,11 @@ async fn handle_container_exec_with_params(
|
||||
match rx_approve.await.unwrap_or_default() {
|
||||
ReviewDecision::Approved => (),
|
||||
ReviewDecision::ApprovedForSession => {
|
||||
sess.add_approved_command(params.command.clone());
|
||||
sess.add_approved_command(ApprovedCommandPattern::new(
|
||||
params.command.clone(),
|
||||
ApprovedCommandMatchKind::Exact,
|
||||
None,
|
||||
));
|
||||
}
|
||||
ReviewDecision::Denied | ReviewDecision::Abort => {
|
||||
return ResponseInputItem::FunctionCallOutput {
|
||||
@@ -6082,7 +6220,11 @@ async fn handle_sandbox_error(
|
||||
// remainder of the session so future
|
||||
// executions skip the sandbox directly.
|
||||
// TODO(ragona): Isn't this a bug? It always saves the command in an | fork?
|
||||
sess.add_approved_command(params.command.clone());
|
||||
sess.add_approved_command(ApprovedCommandPattern::new(
|
||||
params.command.clone(),
|
||||
ApprovedCommandMatchKind::Exact,
|
||||
None,
|
||||
));
|
||||
// Inform UI we are retrying without sandbox.
|
||||
sess.notify_background_event(&sub_id, "retrying command without sandbox")
|
||||
.await;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use crate::codex::ApprovedCommandPattern;
|
||||
use crate::protocol::ApprovedCommandMatchKind;
|
||||
use crate::config_profile::ConfigProfile;
|
||||
use crate::config_types::AgentConfig;
|
||||
use crate::config_types::AllowedCommand;
|
||||
use crate::config_types::AllowedCommandMatchKind;
|
||||
use crate::config_types::BrowserConfig;
|
||||
use crate::config_types::History;
|
||||
use crate::config_types::GithubConfig;
|
||||
@@ -38,6 +42,7 @@ use std::sync::OnceLock;
|
||||
use tempfile::NamedTempFile;
|
||||
use toml::Value as TomlValue;
|
||||
use toml_edit::Array as TomlArray;
|
||||
use toml_edit::ArrayOfTables as TomlArrayOfTables;
|
||||
use toml_edit::DocumentMut;
|
||||
use toml_edit::Item as TomlItem;
|
||||
use toml_edit::Table as TomlTable;
|
||||
@@ -89,6 +94,9 @@ pub struct Config {
|
||||
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
|
||||
/// Commands the user has permanently approved for this project/session.
|
||||
pub always_allow_commands: Vec<ApprovedCommandPattern>,
|
||||
|
||||
pub shell_environment_policy: ShellEnvironmentPolicy,
|
||||
/// Patterns requiring an explicit confirm prefix before running.
|
||||
pub confirm_guard: ConfirmGuardConfig,
|
||||
@@ -884,6 +892,101 @@ pub fn set_project_access_mode(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append a command pattern to `[projects."<path>"].always_allow_commands`.
|
||||
pub fn add_project_allowed_command(
|
||||
codex_home: &Path,
|
||||
project_path: &Path,
|
||||
command: &[String],
|
||||
match_kind: ApprovedCommandMatchKind,
|
||||
) -> anyhow::Result<()> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let read_path = resolve_codex_path_for_read(codex_home, Path::new(CONFIG_TOML_FILE));
|
||||
let mut doc = match std::fs::read_to_string(&read_path) {
|
||||
Ok(s) => s.parse::<DocumentMut>()?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let project_key = project_path.to_string_lossy().to_string();
|
||||
if doc
|
||||
.as_table()
|
||||
.get("projects")
|
||||
.and_then(|i| i.as_table())
|
||||
.is_none()
|
||||
{
|
||||
doc["projects"] = TomlItem::Table(TomlTable::new());
|
||||
}
|
||||
|
||||
let Some(projects_tbl) = doc["projects"].as_table_mut() else {
|
||||
return Err(anyhow::anyhow!("failed to prepare projects table"));
|
||||
};
|
||||
|
||||
if projects_tbl
|
||||
.get(project_key.as_str())
|
||||
.and_then(|i| i.as_table())
|
||||
.is_none()
|
||||
{
|
||||
projects_tbl.insert(project_key.as_str(), TomlItem::Table(TomlTable::new()));
|
||||
}
|
||||
|
||||
let project_tbl = projects_tbl
|
||||
.get_mut(project_key.as_str())
|
||||
.and_then(|i| i.as_table_mut())
|
||||
.ok_or_else(|| anyhow::anyhow!(format!("failed to create projects.{} table", project_key)))?;
|
||||
|
||||
let mut argv_array = TomlArray::new();
|
||||
for arg in command {
|
||||
argv_array.push(arg.clone());
|
||||
}
|
||||
|
||||
let mut table = TomlTable::new();
|
||||
table.insert("argv", TomlItem::Value(toml_edit::Value::Array(argv_array)));
|
||||
let match_str = match match_kind {
|
||||
ApprovedCommandMatchKind::Exact => "exact",
|
||||
ApprovedCommandMatchKind::Prefix => "prefix",
|
||||
};
|
||||
table.insert(
|
||||
"match_kind",
|
||||
TomlItem::Value(toml_edit::Value::from(match_str)),
|
||||
);
|
||||
|
||||
if let Some(existing) = project_tbl
|
||||
.get_mut("always_allow_commands")
|
||||
.and_then(|item| item.as_array_of_tables_mut())
|
||||
{
|
||||
let exists = existing.iter().any(|tbl| {
|
||||
let argv_match = tbl
|
||||
.get("argv")
|
||||
.and_then(|item| item.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(ToString::to_string))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let match_kind = tbl
|
||||
.get("match_kind")
|
||||
.and_then(|item| item.as_str())
|
||||
.unwrap_or("exact");
|
||||
argv_match == command && match_kind.eq_ignore_ascii_case(match_str)
|
||||
});
|
||||
if !exists {
|
||||
existing.push(table);
|
||||
}
|
||||
} else {
|
||||
let mut arr = TomlArrayOfTables::new();
|
||||
arr.push(table);
|
||||
project_tbl.insert("always_allow_commands", TomlItem::ArrayOfTables(arr));
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(codex_home)?;
|
||||
let tmp = NamedTempFile::new_in(codex_home)?;
|
||||
std::fs::write(tmp.path(), doc.to_string())?;
|
||||
tmp.persist(config_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List MCP servers from `CODEX_HOME/config.toml`.
|
||||
/// Returns `(enabled, disabled)` lists of `(name, McpServerConfig)`.
|
||||
pub fn list_mcp_servers(codex_home: &Path) -> anyhow::Result<(
|
||||
@@ -1254,6 +1357,8 @@ pub struct ProjectConfig {
|
||||
pub trust_level: Option<String>,
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
pub sandbox_mode: Option<SandboxMode>,
|
||||
#[serde(default)]
|
||||
pub always_allow_commands: Option<Vec<AllowedCommand>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
@@ -1513,6 +1618,31 @@ impl Config {
|
||||
|
||||
let history = cfg.history.unwrap_or_default();
|
||||
|
||||
let mut always_allow_commands: Vec<ApprovedCommandPattern> = Vec::new();
|
||||
if let Some(project_cfg) = project_override {
|
||||
if let Some(commands) = &project_cfg.always_allow_commands {
|
||||
for cmd in commands {
|
||||
if cmd.argv.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let kind = match cmd.match_kind {
|
||||
AllowedCommandMatchKind::Exact => ApprovedCommandMatchKind::Exact,
|
||||
AllowedCommandMatchKind::Prefix => ApprovedCommandMatchKind::Prefix,
|
||||
};
|
||||
let semantic = if matches!(kind, ApprovedCommandMatchKind::Prefix) {
|
||||
Some(cmd.argv.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
always_allow_commands.push(ApprovedCommandPattern::new(
|
||||
cmd.argv.clone(),
|
||||
kind,
|
||||
semantic,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tools_web_search_request = override_tools_web_search_request
|
||||
.or(cfg.tools.as_ref().and_then(|t| t.web_search))
|
||||
.unwrap_or(false);
|
||||
@@ -1612,6 +1742,7 @@ impl Config {
|
||||
cwd: resolved_cwd,
|
||||
approval_policy: effective_approval,
|
||||
sandbox_policy,
|
||||
always_allow_commands,
|
||||
shell_environment_policy,
|
||||
confirm_guard,
|
||||
disable_response_storage: config_profile
|
||||
@@ -2302,6 +2433,7 @@ model_verbosity = "high"
|
||||
model_provider: fixture.openai_provider.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
always_allow_commands: Vec::new(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: false,
|
||||
user_instructions: None,
|
||||
@@ -2366,6 +2498,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("gpt3".to_string()),
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
always_allow_commands: Vec::new(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: false,
|
||||
user_instructions: None,
|
||||
@@ -2445,6 +2578,7 @@ model_verbosity = "high"
|
||||
active_profile: Some("zdr".to_string()),
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
always_allow_commands: Vec::new(),
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
disable_response_storage: true,
|
||||
user_instructions: None,
|
||||
|
||||
@@ -120,6 +120,15 @@ pub enum Op {
|
||||
decision: ReviewDecision,
|
||||
},
|
||||
|
||||
/// Register a command pattern as approved for the remainder of the session.
|
||||
RegisterApprovedCommand {
|
||||
command: Vec<String>,
|
||||
match_kind: ApprovedCommandMatchKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
semantic_prefix: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Approve a code patch
|
||||
PatchApproval {
|
||||
/// The id of the submission we are approving
|
||||
@@ -176,6 +185,13 @@ pub enum AskForApproval {
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ApprovedCommandMatchKind {
|
||||
Exact,
|
||||
Prefix,
|
||||
}
|
||||
|
||||
/// Determines execution restrictions for model shell commands.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display)]
|
||||
#[strum(serialize_all = "kebab-case")]
|
||||
|
||||
@@ -139,6 +139,15 @@ pub enum Op {
|
||||
decision: ReviewDecision,
|
||||
},
|
||||
|
||||
/// Register a command pattern as approved for the remainder of the session.
|
||||
RegisterApprovedCommand {
|
||||
command: Vec<String>,
|
||||
match_kind: ApprovedCommandMatchKind,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
semantic_prefix: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Approve a code patch
|
||||
PatchApproval {
|
||||
/// The id of the submission we are approving
|
||||
@@ -210,6 +219,13 @@ pub enum AskForApproval {
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, TS)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum ApprovedCommandMatchKind {
|
||||
Exact,
|
||||
Prefix,
|
||||
}
|
||||
|
||||
/// Determines execution restrictions for model shell commands.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, TS)]
|
||||
#[strum(serialize_all = "kebab-case")]
|
||||
|
||||
@@ -12,8 +12,10 @@ use crate::transcript_app::TranscriptApp;
|
||||
use crate::tui;
|
||||
use crate::tui::TerminalInfo;
|
||||
use crate::history_cell;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_login::{AuthManager, AuthMode};
|
||||
use codex_core::config::add_project_allowed_command;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::Op;
|
||||
@@ -911,6 +913,37 @@ impl App<'_> {
|
||||
widget.cancel_running_task_from_approval();
|
||||
}
|
||||
}
|
||||
AppEvent::RegisterApprovedCommand { command, match_kind, persist, semantic_prefix } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.register_approved_command(
|
||||
command.clone(),
|
||||
match_kind.clone(),
|
||||
semantic_prefix.clone(),
|
||||
);
|
||||
if persist {
|
||||
if let Err(err) = add_project_allowed_command(
|
||||
&self.config.codex_home,
|
||||
&self.config.cwd,
|
||||
&command,
|
||||
match_kind.clone(),
|
||||
) {
|
||||
widget.history_push(history_cell::new_error_event(format!(
|
||||
"Failed to persist always-allow command: {err:#}",
|
||||
)));
|
||||
} else {
|
||||
let display = strip_bash_lc_and_escape(&command);
|
||||
widget.history_push(history_cell::new_background_event(format!(
|
||||
"Always allowing `{display}` for this project.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::MarkTaskIdle => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.mark_task_idle_after_denied();
|
||||
}
|
||||
}
|
||||
AppEvent::OpenTerminal(launch) => {
|
||||
let mut spawn = None;
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
|
||||
@@ -2,6 +2,7 @@ use codex_core::config_types::ReasoningEffort;
|
||||
use codex_core::config_types::TextVerbosity;
|
||||
use codex_core::config_types::ThemeName;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::ApprovedCommandMatchKind;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::MouseEvent;
|
||||
@@ -206,6 +207,16 @@ pub(crate) enum AppEvent {
|
||||
/// (clear spinner/status, finalize running exec/tool cells) while the core
|
||||
/// continues its own abort/cleanup in parallel.
|
||||
CancelRunningTask,
|
||||
/// Register a command pattern as approved, optionally persisting to config.
|
||||
RegisterApprovedCommand {
|
||||
command: Vec<String>,
|
||||
match_kind: ApprovedCommandMatchKind,
|
||||
persist: bool,
|
||||
semantic_prefix: Option<Vec<String>>,
|
||||
},
|
||||
/// Indicate that an approval was denied so the UI can clear transient
|
||||
/// spinner/status state without interrupting the core task.
|
||||
MarkTaskIdle,
|
||||
OpenTerminal(TerminalLaunch),
|
||||
TerminalChunk { id: u64, chunk: Vec<u8>, is_stderr: bool },
|
||||
TerminalExit { id: u64, exit_code: Option<i32>, duration: Duration },
|
||||
|
||||
@@ -41,6 +41,7 @@ mod terminal_handlers;
|
||||
mod tools;
|
||||
use codex_core::parse_command::ParsedCommand;
|
||||
use codex_core::protocol::AgentMessageDeltaEvent;
|
||||
use codex_core::protocol::ApprovedCommandMatchKind;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||
use codex_core::protocol::AgentReasoningEvent;
|
||||
@@ -6186,20 +6187,6 @@ impl ChatWidget<'_> {
|
||||
let label = match (&self.config.sandbox_policy, self.config.approval_policy) {
|
||||
(SandboxPolicy::ReadOnly, _) => Some("Read Only".to_string()),
|
||||
(
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access: false,
|
||||
..
|
||||
},
|
||||
AskForApproval::OnRequest,
|
||||
)
|
||||
| (
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access: false,
|
||||
..
|
||||
},
|
||||
AskForApproval::OnFailure,
|
||||
)
|
||||
| (
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access: false,
|
||||
..
|
||||
@@ -6221,20 +6208,6 @@ impl ChatWidget<'_> {
|
||||
let idx = match (&self.config.sandbox_policy, self.config.approval_policy) {
|
||||
(SandboxPolicy::ReadOnly, _) => 0,
|
||||
(
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access: false,
|
||||
..
|
||||
},
|
||||
AskForApproval::OnRequest,
|
||||
)
|
||||
| (
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access: false,
|
||||
..
|
||||
},
|
||||
AskForApproval::OnFailure,
|
||||
)
|
||||
| (
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access: false,
|
||||
..
|
||||
@@ -6242,7 +6215,7 @@ impl ChatWidget<'_> {
|
||||
AskForApproval::UnlessTrusted,
|
||||
) => 1,
|
||||
(SandboxPolicy::DangerFullAccess, AskForApproval::Never) => 2,
|
||||
_ => 1,
|
||||
_ => 0,
|
||||
};
|
||||
let next = (idx + 1) % 3;
|
||||
|
||||
@@ -6255,7 +6228,7 @@ impl ChatWidget<'_> {
|
||||
),
|
||||
1 => (
|
||||
"Write with Approval",
|
||||
AskForApproval::OnRequest,
|
||||
AskForApproval::UnlessTrusted,
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
),
|
||||
_ => (
|
||||
@@ -6602,6 +6575,42 @@ impl ChatWidget<'_> {
|
||||
self.interrupt_running_task();
|
||||
}
|
||||
|
||||
pub(crate) fn register_approved_command(
|
||||
&self,
|
||||
command: Vec<String>,
|
||||
match_kind: ApprovedCommandMatchKind,
|
||||
semantic_prefix: Option<Vec<String>>,
|
||||
) {
|
||||
if command.is_empty() {
|
||||
return;
|
||||
}
|
||||
let op = Op::RegisterApprovedCommand {
|
||||
command,
|
||||
match_kind,
|
||||
semantic_prefix,
|
||||
};
|
||||
self.submit_op(op);
|
||||
}
|
||||
|
||||
/// Clear transient spinner/status after a denial without interrupting core
|
||||
/// execution. Only hide the spinner when there is no remaining activity so
|
||||
/// we avoid masking in-flight work (e.g. follow-up reasoning).
|
||||
pub(crate) fn mark_task_idle_after_denied(&mut self) {
|
||||
let any_tools_running = !self.exec.running_commands.is_empty()
|
||||
|| !self.tools_state.running_custom_tools.is_empty()
|
||||
|| !self.tools_state.running_web_search.is_empty();
|
||||
let any_streaming = self.stream.is_write_cycle_active();
|
||||
let any_agents_active = self.agents_are_actively_running();
|
||||
let any_tasks_active = !self.active_task_ids.is_empty();
|
||||
|
||||
if !(any_tools_running || any_streaming || any_agents_active || any_tasks_active) {
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.bottom_pane.update_status_text(String::new());
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn insert_history_lines(&mut self, lines: Vec<ratatui::text::Line<'static>>) {
|
||||
let kind = self.stream_state.current_kind.unwrap_or(StreamKind::Answer);
|
||||
self.insert_history_lines_with_kind(kind, None, lines);
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
//! UI to Rust using [`ratatui`]. The goal is feature‑parity for the keyboard
|
||||
//! driven workflow – a fully‑fledged visual match is not required.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -17,18 +16,19 @@ use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
use shlex::split as shlex_split;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use codex_core::protocol::ApprovedCommandMatchKind;
|
||||
|
||||
/// Request coming from the agent that needs user approval.
|
||||
pub(crate) enum ApprovalRequest {
|
||||
@@ -44,62 +44,32 @@ pub(crate) enum ApprovalRequest {
|
||||
},
|
||||
}
|
||||
|
||||
/// Options displayed in the *select* mode.
|
||||
///
|
||||
/// The `key` is matched case-insensitively.
|
||||
#[derive(Clone)]
|
||||
struct SelectOption {
|
||||
label: Line<'static>,
|
||||
description: &'static str,
|
||||
key: KeyCode,
|
||||
decision: ReviewDecision,
|
||||
label: String,
|
||||
description: String,
|
||||
hotkey: KeyCode,
|
||||
action: SelectAction,
|
||||
}
|
||||
|
||||
static COMMAND_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
|
||||
vec![
|
||||
SelectOption {
|
||||
label: Line::from(vec!["Y".underlined(), "es".into()]),
|
||||
description: "Approve and run the command",
|
||||
key: KeyCode::Char('y'),
|
||||
decision: ReviewDecision::Approved,
|
||||
},
|
||||
SelectOption {
|
||||
label: Line::from(vec!["A".underlined(), "lways".into()]),
|
||||
description: "Approve the command for the remainder of this session",
|
||||
key: KeyCode::Char('a'),
|
||||
decision: ReviewDecision::ApprovedForSession,
|
||||
},
|
||||
SelectOption {
|
||||
label: Line::from(vec!["N".underlined(), "o, provide feedback".into()]),
|
||||
description: "Do not run the command; provide feedback",
|
||||
key: KeyCode::Char('n'),
|
||||
decision: ReviewDecision::Abort,
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
static PATCH_SELECT_OPTIONS: LazyLock<Vec<SelectOption>> = LazyLock::new(|| {
|
||||
vec![
|
||||
SelectOption {
|
||||
label: Line::from(vec!["Y".underlined(), "es".into()]),
|
||||
description: "Approve and apply the changes",
|
||||
key: KeyCode::Char('y'),
|
||||
decision: ReviewDecision::Approved,
|
||||
},
|
||||
SelectOption {
|
||||
label: Line::from(vec!["N".underlined(), "o, provide feedback".into()]),
|
||||
description: "Do not apply the changes; provide feedback",
|
||||
key: KeyCode::Char('n'),
|
||||
decision: ReviewDecision::Abort,
|
||||
},
|
||||
]
|
||||
});
|
||||
#[derive(Clone)]
|
||||
enum SelectAction {
|
||||
ApproveOnce,
|
||||
ApproveForSession {
|
||||
command: Vec<String>,
|
||||
match_kind: ApprovedCommandMatchKind,
|
||||
persist: bool,
|
||||
semantic_prefix: Option<Vec<String>>,
|
||||
},
|
||||
Abort,
|
||||
}
|
||||
|
||||
/// A modal prompting the user to approve or deny the pending request.
|
||||
pub(crate) struct UserApprovalWidget<'a> {
|
||||
approval_request: ApprovalRequest,
|
||||
app_event_tx: AppEventSender,
|
||||
confirmation_prompt: Paragraph<'a>,
|
||||
select_options: &'a Vec<SelectOption>,
|
||||
select_options: Vec<SelectOption>,
|
||||
|
||||
/// Currently selected index in *select* mode.
|
||||
selected_option: usize,
|
||||
@@ -156,14 +126,16 @@ impl UserApprovalWidget<'_> {
|
||||
}
|
||||
};
|
||||
|
||||
let select_options = match &approval_request {
|
||||
ApprovalRequest::Exec { command, .. } => build_exec_select_options(command),
|
||||
ApprovalRequest::ApplyPatch { .. } => build_patch_select_options(),
|
||||
};
|
||||
|
||||
Self {
|
||||
select_options: match &approval_request {
|
||||
ApprovalRequest::Exec { .. } => &COMMAND_SELECT_OPTIONS,
|
||||
ApprovalRequest::ApplyPatch { .. } => &PATCH_SELECT_OPTIONS,
|
||||
},
|
||||
approval_request,
|
||||
app_event_tx,
|
||||
confirmation_prompt,
|
||||
select_options,
|
||||
selected_option: 0,
|
||||
done: false,
|
||||
}
|
||||
@@ -210,29 +182,39 @@ impl UserApprovalWidget<'_> {
|
||||
}
|
||||
|
||||
fn handle_select_key(&mut self, key_event: KeyEvent) {
|
||||
let len = self.select_options.len();
|
||||
if len == 0 {
|
||||
return;
|
||||
}
|
||||
match key_event.code {
|
||||
KeyCode::Left => {
|
||||
self.selected_option = (self.selected_option + self.select_options.len() - 1)
|
||||
% self.select_options.len();
|
||||
KeyCode::Up | KeyCode::Left => {
|
||||
self.selected_option = if self.selected_option == 0 {
|
||||
len - 1
|
||||
} else {
|
||||
self.selected_option - 1
|
||||
};
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.selected_option = (self.selected_option + 1) % self.select_options.len();
|
||||
KeyCode::Down | KeyCode::Right => {
|
||||
self.selected_option = (self.selected_option + 1) % len;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let opt = &self.select_options[self.selected_option];
|
||||
self.send_decision(opt.decision);
|
||||
if let Some(option) = self.select_options.get(self.selected_option).cloned() {
|
||||
self.perform_action(option.action);
|
||||
}
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.send_decision(ReviewDecision::Abort);
|
||||
self.perform_action(SelectAction::Abort);
|
||||
}
|
||||
other => {
|
||||
let normalized = Self::normalize_keycode(other);
|
||||
if let Some(opt) = self
|
||||
if let Some((idx, option)) = self
|
||||
.select_options
|
||||
.iter()
|
||||
.find(|opt| Self::normalize_keycode(opt.key) == normalized)
|
||||
.enumerate()
|
||||
.find(|(_, opt)| Self::normalize_keycode(opt.hotkey) == normalized)
|
||||
{
|
||||
self.send_decision(opt.decision);
|
||||
self.selected_option = idx;
|
||||
self.perform_action(option.action.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,8 +253,20 @@ impl UserApprovalWidget<'_> {
|
||||
// If the user aborted an exec approval, immediately cancel any running task
|
||||
// so the UI reflects their intent (clear spinner/status) without waiting
|
||||
// for backend cleanup. Core still receives the Abort below.
|
||||
if matches!((&self.approval_request, decision), (ApprovalRequest::Exec { .. }, ReviewDecision::Abort)) {
|
||||
self.app_event_tx.send(AppEvent::CancelRunningTask);
|
||||
match (&self.approval_request, decision) {
|
||||
(ApprovalRequest::Exec { .. }, ReviewDecision::Abort) => {
|
||||
self.app_event_tx.send(AppEvent::CancelRunningTask);
|
||||
}
|
||||
(ApprovalRequest::Exec { .. }, ReviewDecision::Denied) => {
|
||||
self.app_event_tx.send(AppEvent::MarkTaskIdle);
|
||||
}
|
||||
(ApprovalRequest::ApplyPatch { .. }, ReviewDecision::Abort) => {
|
||||
self.app_event_tx.send(AppEvent::CancelRunningTask);
|
||||
}
|
||||
(ApprovalRequest::ApplyPatch { .. }, ReviewDecision::Denied) => {
|
||||
self.app_event_tx.send(AppEvent::MarkTaskIdle);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let op = match &self.approval_request {
|
||||
@@ -290,6 +284,31 @@ impl UserApprovalWidget<'_> {
|
||||
self.done = true;
|
||||
}
|
||||
|
||||
fn perform_action(&mut self, action: SelectAction) {
|
||||
match action {
|
||||
SelectAction::ApproveOnce => {
|
||||
self.send_decision(ReviewDecision::Approved);
|
||||
}
|
||||
SelectAction::ApproveForSession {
|
||||
command,
|
||||
match_kind,
|
||||
persist,
|
||||
semantic_prefix,
|
||||
} => {
|
||||
self.app_event_tx.send(AppEvent::RegisterApprovedCommand {
|
||||
command: command.clone(),
|
||||
match_kind: match_kind.clone(),
|
||||
persist,
|
||||
semantic_prefix: semantic_prefix.clone(),
|
||||
});
|
||||
self.send_decision(ReviewDecision::ApprovedForSession);
|
||||
}
|
||||
SelectAction::Abort => {
|
||||
self.send_decision(ReviewDecision::Abort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` once the user has made a decision and the widget no
|
||||
/// longer needs to be displayed.
|
||||
pub(crate) fn is_complete(&self) -> bool {
|
||||
@@ -297,75 +316,193 @@ impl UserApprovalWidget<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn desired_height(&self, width: u16) -> u16 {
|
||||
// Reserve space for:
|
||||
// - 1 title line ("Allow command?" or "Apply changes?")
|
||||
// - 1 buttons line (options rendered horizontally on a single row)
|
||||
// - 1 description line (context for the currently selected option)
|
||||
self.get_confirmation_prompt_height(width) + 3
|
||||
let prompt = self.get_confirmation_prompt_height(width);
|
||||
let option_lines = (self.select_options.len() as u16).saturating_mul(2);
|
||||
prompt + option_lines + 2
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &UserApprovalWidget<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let prompt_height = self.get_confirmation_prompt_height(area.width);
|
||||
let [prompt_chunk, response_chunk] = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(prompt_height), Constraint::Min(0)])
|
||||
.areas(area);
|
||||
|
||||
let lines: Vec<Line> = self
|
||||
.select_options
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, opt)| {
|
||||
let style = if idx == self.selected_option {
|
||||
Style::new()
|
||||
.bg(crate::colors::light_blue())
|
||||
.fg(crate::colors::background())
|
||||
} else {
|
||||
Style::new().add_modifier(Modifier::DIM)
|
||||
};
|
||||
opt.label.clone().alignment(Alignment::Center).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let [title_area, button_area, description_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
let [prompt_chunk, options_chunk] = Layout::vertical([
|
||||
Constraint::Length(prompt_height),
|
||||
Constraint::Min(0),
|
||||
])
|
||||
.areas(response_chunk.inner(Margin::new(1, 0)));
|
||||
let title = match &self.approval_request {
|
||||
ApprovalRequest::Exec { .. } => "Allow command?",
|
||||
ApprovalRequest::ApplyPatch { .. } => "Apply changes?",
|
||||
};
|
||||
Line::from(title).render(title_area, buf);
|
||||
.areas(area);
|
||||
|
||||
self.confirmation_prompt.clone().render(prompt_chunk, buf);
|
||||
let areas = Layout::horizontal(
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| Constraint::Length(l.width() as u16 + 2)),
|
||||
)
|
||||
.spacing(1)
|
||||
.split(button_area);
|
||||
for (idx, area) in areas.iter().enumerate() {
|
||||
let line = &lines[idx];
|
||||
line.render(*area, buf);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for (idx, option) in self.select_options.iter().enumerate() {
|
||||
let selected = idx == self.selected_option;
|
||||
let indicator = if selected { "› " } else { " " };
|
||||
let line_style = if selected {
|
||||
Style::default()
|
||||
.fg(crate::colors::primary())
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let label = format!("{}{}{}", indicator, option.label, hotkey_suffix(option.hotkey));
|
||||
lines.push(Line::from(Span::styled(label, line_style)));
|
||||
|
||||
let desc_style = Style::default()
|
||||
.fg(crate::colors::text_dim())
|
||||
.add_modifier(Modifier::ITALIC);
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", option.description),
|
||||
desc_style,
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
if !lines.is_empty() {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
Line::from(self.select_options[self.selected_option].description)
|
||||
.style(Style::new().italic().add_modifier(Modifier::DIM))
|
||||
.render(description_area.inner(Margin::new(1, 0)), buf);
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(options_chunk.inner(Margin::new(1, 0)), buf);
|
||||
|
||||
Block::bordered()
|
||||
.border_type(BorderType::QuadrantOutside)
|
||||
.border_style(Style::default().fg(crate::colors::light_blue()))
|
||||
.borders(Borders::LEFT)
|
||||
.render_ref(
|
||||
Rect::new(0, response_chunk.y, 1, response_chunk.height),
|
||||
buf,
|
||||
);
|
||||
.render_ref(Rect::new(0, options_chunk.y, 1, options_chunk.height), buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_exec_select_options(command: &[String]) -> Vec<SelectOption> {
|
||||
let mut options = Vec::new();
|
||||
|
||||
options.push(SelectOption {
|
||||
label: "Yes".to_string(),
|
||||
description: "Approve and run the command".to_string(),
|
||||
hotkey: KeyCode::Char('y'),
|
||||
action: SelectAction::ApproveOnce,
|
||||
});
|
||||
|
||||
let full_display = strip_bash_lc_and_escape(command);
|
||||
options.push(SelectOption {
|
||||
label: format!("Always allow '{full_display}' for this project"),
|
||||
description: "Approve this exact command automatically next time".to_string(),
|
||||
hotkey: KeyCode::Char('a'),
|
||||
action: SelectAction::ApproveForSession {
|
||||
command: command.to_vec(),
|
||||
match_kind: ApprovedCommandMatchKind::Exact,
|
||||
persist: true,
|
||||
semantic_prefix: None,
|
||||
},
|
||||
});
|
||||
|
||||
let normalized_tokens = normalized_command_tokens(command);
|
||||
if let Some(tokens) = normalized_tokens.as_ref() {
|
||||
if let Some(prefix) = prefix_candidate(tokens) {
|
||||
let prefix_display = strip_bash_lc_and_escape(&prefix);
|
||||
let prefix_with_wildcard = format!("{prefix_display} *");
|
||||
options.push(SelectOption {
|
||||
label: format!("Always allow '{prefix_with_wildcard}' for this project"),
|
||||
description: "Approve any command starting with this prefix".to_string(),
|
||||
hotkey: KeyCode::Char('p'),
|
||||
action: SelectAction::ApproveForSession {
|
||||
command: prefix.clone(),
|
||||
match_kind: ApprovedCommandMatchKind::Prefix,
|
||||
persist: true,
|
||||
semantic_prefix: Some(prefix),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
options.push(SelectOption {
|
||||
label: "No, provide feedback".to_string(),
|
||||
description: "Do not run the command; provide feedback".to_string(),
|
||||
hotkey: KeyCode::Char('n'),
|
||||
action: SelectAction::Abort,
|
||||
});
|
||||
|
||||
options
|
||||
}
|
||||
|
||||
fn build_patch_select_options() -> Vec<SelectOption> {
|
||||
vec![
|
||||
SelectOption {
|
||||
label: "Yes".to_string(),
|
||||
description: "Approve and apply the changes".to_string(),
|
||||
hotkey: KeyCode::Char('y'),
|
||||
action: SelectAction::ApproveOnce,
|
||||
},
|
||||
SelectOption {
|
||||
label: "No, provide feedback".to_string(),
|
||||
description: "Do not apply the changes; provide feedback".to_string(),
|
||||
hotkey: KeyCode::Char('n'),
|
||||
action: SelectAction::Abort,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn normalized_command_tokens(command: &[String]) -> Option<Vec<String>> {
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if command.len() == 3 && is_shell_wrapper(&command[0], &command[1]) {
|
||||
if let Some(script_tokens) = shlex_split(&command[2]) {
|
||||
return Some(script_tokens);
|
||||
}
|
||||
return Some(vec![command[2].clone()]);
|
||||
}
|
||||
|
||||
Some(command.to_vec())
|
||||
}
|
||||
|
||||
fn prefix_candidate(tokens: &[String]) -> Option<Vec<String>> {
|
||||
if tokens.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut prefix: Vec<String> = Vec::with_capacity(tokens.len());
|
||||
for (idx, token) in tokens.iter().enumerate() {
|
||||
if idx == 0 {
|
||||
prefix.push(token.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
if token.starts_with('-')
|
||||
|| token.contains('/')
|
||||
|| token.contains('.')
|
||||
|| token.contains('\\')
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
prefix.push(token.clone());
|
||||
if prefix.len() == 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if prefix.len() >= 2 && prefix.len() < tokens.len() {
|
||||
Some(prefix)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_shell_wrapper(shell: &str, flag: &str) -> bool {
|
||||
let file_name = Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(shell)
|
||||
.to_ascii_lowercase();
|
||||
matches!(file_name.as_str(), "bash" | "sh" | "zsh") && matches!(flag, "-lc" | "-c")
|
||||
}
|
||||
|
||||
fn hotkey_suffix(key: KeyCode) -> String {
|
||||
match key {
|
||||
KeyCode::Char(c) => format!(" ({})", c.to_ascii_lowercase()),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,6 +513,7 @@ mod tests {
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use std::sync::mpsc::channel;
|
||||
use codex_core::protocol::ApprovedCommandMatchKind;
|
||||
|
||||
#[test]
|
||||
fn lowercase_shortcut_is_accepted() {
|
||||
@@ -399,6 +537,92 @@ mod tests {
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn always_option_registers_command() {
|
||||
let (tx_raw, rx) = channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let req = ApprovalRequest::Exec {
|
||||
id: "1".to_string(),
|
||||
command: vec!["git".into(), "status".into()],
|
||||
reason: None,
|
||||
};
|
||||
let mut widget = UserApprovalWidget::new(req, tx);
|
||||
widget.handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
|
||||
|
||||
let events: Vec<AppEvent> = rx.try_iter().collect();
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::RegisterApprovedCommand {
|
||||
command,
|
||||
match_kind: ApprovedCommandMatchKind::Exact,
|
||||
persist: true,
|
||||
semantic_prefix: None,
|
||||
} if command == &vec!["git".to_string(), "status".to_string()]
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_option_registers_prefix_command() {
|
||||
let (tx_raw, rx) = channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let req = ApprovalRequest::Exec {
|
||||
id: "3".to_string(),
|
||||
command: vec![
|
||||
"git".to_string(),
|
||||
"checkout".to_string(),
|
||||
"--".to_string(),
|
||||
"README.md".to_string(),
|
||||
],
|
||||
reason: None,
|
||||
};
|
||||
let mut widget = UserApprovalWidget::new(req, tx);
|
||||
widget.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
|
||||
|
||||
let events: Vec<AppEvent> = rx.try_iter().collect();
|
||||
assert!(events.iter().any(|event| matches!(
|
||||
event,
|
||||
AppEvent::RegisterApprovedCommand {
|
||||
command,
|
||||
match_kind: ApprovedCommandMatchKind::Prefix,
|
||||
persist: true,
|
||||
semantic_prefix: Some(prefix)
|
||||
} if command == &vec!["git".to_string(), "checkout".to_string()]
|
||||
&& prefix == vec!["git".to_string(), "checkout".to_string()]
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_candidate_skips_flags_and_paths() {
|
||||
assert_eq!(prefix_candidate(&["git".into(), "status".into()]), None);
|
||||
assert_eq!(
|
||||
prefix_candidate(&["git".into(), "checkout".into(), "--".into(), "file".into()]),
|
||||
Some(vec!["git".into(), "checkout".into()])
|
||||
);
|
||||
assert_eq!(
|
||||
prefix_candidate(&["aws".into(), "s3".into(), "cp".into(), "foo".into(), "bar".into()]),
|
||||
Some(vec!["aws".into(), "s3".into(), "cp".into()])
|
||||
);
|
||||
assert_eq!(
|
||||
prefix_candidate(&["docker".into(), "build".into(), "-t".into(), "image".into(), ".".into()]),
|
||||
Some(vec!["docker".into(), "build".into()])
|
||||
);
|
||||
assert_eq!(
|
||||
prefix_candidate(&["echo".into(), "hello".into(), "world".into()]),
|
||||
None
|
||||
);
|
||||
|
||||
// Shell-wrapped script
|
||||
let normalized = normalized_command_tokens(&[
|
||||
"bash".into(),
|
||||
"-lc".into(),
|
||||
"git checkout -- README.md".into(),
|
||||
]);
|
||||
assert_eq!(
|
||||
normalized.as_ref().and_then(|tokens| prefix_candidate(tokens)),
|
||||
Some(vec!["git".into(), "checkout".into()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_shortcut_is_accepted() {
|
||||
let (tx_raw, rx) = channel::<AppEvent>();
|
||||
|
||||
Reference in New Issue
Block a user