Compare commits

...

50 Commits

Author SHA1 Message Date
easong-openai
813f27cf1e more tests 2025-08-04 14:04:08 -07:00
easong-openai
039bf0a13d fmt 2025-08-04 11:53:20 -07:00
easong-openai
86ee572910 warning 2025-08-04 11:48:22 -07:00
easong-openai
f2ad7ec313 comments 2025-08-04 11:47:15 -07:00
easong-openai
8e363f0dbc Merge remote-tracking branch 'origin/main' into multiple-slash-commands 2025-08-04 11:33:26 -07:00
easong-openai
726cf674fd Merge remote-tracking branch 'origin/main' into multiple-slash-commands 2025-08-04 11:22:52 -07:00
easong-openai
ba366f6d38 merge 2025-08-04 11:22:36 -07:00
easong-openai
e112464f99 format 2025-08-04 10:23:50 -07:00
easong-openai
16e1a5b45b as is 2025-08-04 01:02:54 -07:00
easong-openai
20ebd6b2ce cleaup 2025-08-03 13:10:24 -07:00
easong-openai
07745aad34 Merge branch 'main' into multiple-slash-commands 2025-08-03 10:59:51 -07:00
easong-openai
586be04491 clippy 2025-08-03 01:33:46 -07:00
easong-openai
8a61d397d4 improvements, danger widget 2025-08-02 23:05:13 -07:00
easong-openai
a983c1f6ad better approval flow, model switching, etc 2025-08-02 17:05:15 -07:00
easong-openai
b23cddd1d9 add approvals slash command, models option 2025-08-02 13:55:53 -07:00
easong-openai
aec9a8b9b4 Merge remote-tracking branch 'origin/main' into multiple-slash-commands 2025-08-02 12:05:29 -07:00
easong-openai
1448a11697 merge /models 2025-08-02 12:01:24 -07:00
easong-openai
f0d9f24fc0 improvements, more dry 2025-08-01 20:01:49 -07:00
easong-openai
60e9eb683c tests 2025-08-01 17:38:30 -07:00
easong-openai
f7f43f1b5b Merge remote-tracking branch 'origin/main' into scrollable-slash-menu 2025-08-01 10:15:54 -07:00
easong-openai
9c0f8a50b6 comments 2025-08-01 10:15:07 -07:00
easong-openai
53c19b4d07 tests, scroll to bottom from top 2025-08-01 05:00:45 -07:00
easong-openai
9f45d477e5 comments 2025-08-01 04:34:40 -07:00
easong-openai
1aad659eba scrollable slash menu, esc to exit 2025-08-01 04:33:01 -07:00
pap-openai
5d4ade38a4 Merge branch 'main' into pap/model-selection 2025-08-01 01:48:42 +01:00
pap-openai
4f2f4dcf6f Merge branch 'main' into pap/model-selection 2025-08-01 00:05:35 +01:00
pap
8dea0e4cd2 fuzzy is now a common lib + toml alphabetical order 2025-08-01 00:04:24 +01:00
pap
145688f019 linter 2025-07-31 23:09:55 +01:00
pap
1afa537148 remove useless code 2025-07-31 22:48:51 +01:00
pap
507f79deac linter 2025-07-31 22:34:12 +01:00
pap
d207169ea6 Merge branch 'main' into pap/model-selection 2025-07-31 22:29:45 +01:00
pap
4e2cf0bb7a can set non default models 2025-07-31 22:28:54 +01:00
pap
56e95f7ec7 scrollable model list 2025-07-31 21:54:42 +01:00
pap
fbc1ee7d62 new model popup 2025-07-31 15:00:08 +01:00
pap
f8e5b02320 desired_height for model selection 2025-07-31 13:35:59 +01:00
pap
02d16813bf Merge branch 'main' into pap/model-selection 2025-07-31 13:35:42 +01:00
pap-openai
7cf524d8b9 Merge branch 'main' into pap/model-selection 2025-07-30 22:49:44 +01:00
pap
40cf8a819c lint test 2025-07-30 22:28:02 +01:00
pap
55659e351c fixing merge 2025-07-30 22:00:13 +01:00
pap
2326f99e03 Merge branch 'main' into pap/model-selection 2025-07-30 21:49:14 +01:00
pap
91aa683ae9 cleaner code 2025-07-30 20:43:31 +01:00
pap
9dce0d7882 don't show session information at each reconfiguration 2025-07-30 20:32:01 +01:00
pap
661a4ff3f9 fix: self.emit_last_history_entry() 2025-07-30 20:07:14 +01:00
pap-openai
da3f90fdad Merge branch 'main' into pap/model-selection 2025-07-30 20:02:25 +01:00
pap-openai
fcbe6495f1 Merge branch 'main' into pap/model-selection 2025-07-30 18:06:38 +01:00
pap
34edf573d7 linter 2025-07-30 18:06:11 +01:00
pap
f78f8d8c7c fmt 2025-07-29 23:20:24 +01:00
pap
1836614c06 remove current model if search doesn't match 2025-07-28 23:25:35 +01:00
pap
9db5c7af9e remove preference ranking as we don't get models dynamically 2025-07-28 23:21:49 +01:00
pap
b294004ea9 adding /model 2025-07-28 23:06:30 +01:00
29 changed files with 2585 additions and 434 deletions

20
codex-rs/Cargo.lock generated
View File

@@ -765,8 +765,8 @@ version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-common",
"ignore",
"nucleo-matcher",
"serde",
"serde_json",
"tokio",
@@ -873,6 +873,7 @@ dependencies = [
"supports-color",
"textwrap 0.16.2",
"tokio",
"toml 0.8.23",
"tracing",
"tracing-appender",
"tracing-subscriber",
@@ -2812,16 +2813,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "nucleo-matcher"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
dependencies = [
"memchr",
"unicode-segmentation",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -4814,6 +4805,7 @@ dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow",
]
@@ -4826,6 +4818,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.0.2"

View File

@@ -43,6 +43,10 @@ To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the p
Typing `@` triggers a fuzzy-filename search over the workspace root. Use up/down to select among the results and Tab or Enter to replace the `@` with the selected path. You can use Esc to cancel the search.
### Slash commands and model selection
Type `/` in the composer to open a command menu. Navigate with Up/Down and press Tab or Enter to apply a command. The list supports filtering by typing after the slash. Use `/model` to select or change the active model; after typing `/model ` (note the space), a model selector appears with fuzzy filtering and keyboard navigation. Press Enter or Tab to apply the selection.
### `--cd`/`-C` flag
Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session.

View File

@@ -17,3 +17,8 @@ toml = { version = "0.9", optional = true }
cli = ["clap", "serde", "toml"]
elapsed = []
sandbox_summary = []
[dev-dependencies]
clap = { version = "4", features = ["derive", "wrap_help"] }
serde = { version = "1" }
toml = { version = "0.9" }

View File

@@ -0,0 +1,155 @@
/// Simple case-insensitive subsequence matcher used for fuzzy filtering.
///
/// Returns the indices (character positions) of the matched characters in the
/// ORIGINAL `haystack` string and a score where smaller is better.
///
/// Unicode correctness: we perform the match on a lowercased copy of the
/// haystack and needle but maintain a mapping from each character in the
/// lowercased haystack back to the original character index in `haystack`.
/// This ensures the returned indices can be safely used with
/// `str::chars().enumerate()` consumers for highlighting, even when
/// lowercasing expands certain characters (e.g., ß → ss, İ → i̇).
pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
if needle.is_empty() {
return Some((Vec::new(), i32::MAX));
}
let mut lowered_chars: Vec<char> = Vec::new();
let mut lowered_to_orig_char_idx: Vec<usize> = Vec::new();
for (orig_idx, ch) in haystack.chars().enumerate() {
for lc in ch.to_lowercase() {
lowered_chars.push(lc);
lowered_to_orig_char_idx.push(orig_idx);
}
}
let lowered_needle: Vec<char> = needle.to_lowercase().chars().collect();
let mut result_orig_indices: Vec<usize> = Vec::with_capacity(lowered_needle.len());
let mut last_lower_pos: Option<usize> = None;
let mut cur = 0usize;
for &nc in lowered_needle.iter() {
let mut found_at: Option<usize> = None;
while cur < lowered_chars.len() {
if lowered_chars[cur] == nc {
found_at = Some(cur);
cur += 1;
break;
}
cur += 1;
}
let pos = found_at?;
result_orig_indices.push(lowered_to_orig_char_idx[pos]);
last_lower_pos = Some(pos);
}
let first_lower_pos = if result_orig_indices.is_empty() {
0usize
} else {
let target_orig = result_orig_indices[0];
lowered_to_orig_char_idx
.iter()
.position(|&oi| oi == target_orig)
.unwrap_or(0)
};
let last_lower_pos = last_lower_pos.unwrap_or(first_lower_pos);
let window =
(last_lower_pos as i32 - first_lower_pos as i32 + 1) - (lowered_needle.len() as i32);
let mut score = window.max(0);
if first_lower_pos == 0 {
score -= 100;
}
result_orig_indices.sort_unstable();
result_orig_indices.dedup();
Some((result_orig_indices, score))
}
/// Convenience wrapper to get only the indices for a fuzzy match.
pub fn fuzzy_indices(haystack: &str, needle: &str) -> Option<Vec<usize>> {
fuzzy_match(haystack, needle).map(|(mut idx, _)| {
idx.sort_unstable();
idx.dedup();
idx
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ascii_basic_indices() {
let (idx, _score) = match fuzzy_match("hello", "hl") {
Some(v) => v,
None => panic!("expected a match"),
};
assert_eq!(idx, vec![0, 2]);
}
#[test]
fn unicode_dotted_i_istanbul_highlighting() {
let (idx, _score) = match fuzzy_match("İstanbul", "is") {
Some(v) => v,
None => panic!("expected a match"),
};
assert_eq!(idx, vec![0, 1]);
}
#[test]
fn unicode_german_sharp_s_casefold() {
assert!(fuzzy_match("straße", "strasse").is_none());
}
#[test]
fn prefer_contiguous_match_over_spread() {
// Contiguous match should receive a better (smaller) score than a
// spread-out match because the window penalty is larger when
// characters are farther apart.
let (_idx_a, score_a) = fuzzy_match("abc", "abc").expect("expected match");
let (_idx_b, score_b) = fuzzy_match("a-b-c", "abc").expect("expected match");
assert!(
score_a < score_b,
"contiguous match should rank better: {score_a} < {score_b}"
);
}
#[test]
fn start_of_string_bonus_applies() {
// Matches that begin at index 0 get a large bonus (-100), so
// "file_name" should outrank "my_file_name" for the pattern "file".
let (_idx_a, score_a) = fuzzy_match("file_name", "file").expect("expected match");
let (_idx_b, score_b) = fuzzy_match("my_file_name", "file").expect("expected match");
assert!(
score_a < score_b,
"start-of-string bonus should apply: {score_a} < {score_b}"
);
}
#[test]
fn empty_needle_matches_with_max_score_and_no_indices() {
let (idx, score) = fuzzy_match("anything", "").expect("empty needle should match");
assert!(idx.is_empty());
assert_eq!(score, i32::MAX);
}
#[test]
fn case_insensitive_matching_basic() {
// Verify case-insensitivity: mixed-case needle should match and
// indices should refer to original haystack character positions.
let (idx, _score) = fuzzy_match("Hello", "heL").expect("expected match");
assert_eq!(idx, vec![0, 1, 2]);
}
#[test]
fn indices_are_deduped_for_multichar_lowercase_expansion() {
// U+0130 LATIN CAPITAL LETTER I WITH DOT ABOVE lowercases to two code
// points: 'i' + U+0307 COMBINING DOT ABOVE. When the needle matches
// both of these lowered code points, the resulting original indices
// would be [0, 0] (same original char). The implementation should
// return unique, sorted indices.
let needle = "\u{0069}\u{0307}"; // "i" + combining dot above
let (idx, _score) = fuzzy_match("İ", needle).expect("expected match");
assert_eq!(idx, vec![0], "indices should be unique and sorted");
}
}

View File

@@ -23,3 +23,5 @@ mod sandbox_summary;
#[cfg(feature = "sandbox_summary")]
pub use sandbox_summary::summarize_sandbox_policy;
pub mod fuzzy_match;

View File

@@ -127,20 +127,8 @@ impl Codex {
let user_instructions = get_user_instructions(&config).await;
let configure_session = Op::ConfigureSession {
provider: config.model_provider.clone(),
model: config.model.clone(),
model_reasoning_effort: config.model_reasoning_effort,
model_reasoning_summary: config.model_reasoning_summary,
user_instructions,
base_instructions: config.base_instructions.clone(),
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy.clone(),
disable_response_storage: config.disable_response_storage,
notify: config.notify.clone(),
cwd: config.cwd.clone(),
resume_path: resume_path.clone(),
};
let configure_session =
config.to_configure_session_op(Some(config.model.clone()), user_instructions);
let config = Arc::new(config);
@@ -752,8 +740,14 @@ async fn submission_loop(
}
};
let client_config = {
let mut c = (*config).clone();
c.model = model.clone();
Arc::new(c)
};
let client = ModelClient::new(
config.clone(),
client_config,
auth.clone(),
provider.clone(),
model_reasoning_effort,

View File

@@ -14,6 +14,7 @@ use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::built_in_model_providers;
use crate::openai_model_info::get_model_info;
use crate::protocol::AskForApproval;
use crate::protocol::Op;
use crate::protocol::SandboxPolicy;
use dirs::home_dir;
use serde::Deserialize;
@@ -185,6 +186,32 @@ impl Config {
// Step 4: merge with the strongly-typed overrides.
Self::load_from_base_config_with_overrides(cfg, overrides, codex_home)
}
/// Construct an Op::ConfigureSession from this Config.
///
/// - `override_model`: when Some, use this model instead of `self.model`.
/// - `user_instructions`: pass-through instructions to embed in the session.
pub fn to_configure_session_op(
&self,
override_model: Option<String>,
user_instructions: Option<String>,
) -> Op {
let model = override_model.unwrap_or_else(|| self.model.clone());
Op::ConfigureSession {
provider: self.model_provider.clone(),
model,
model_reasoning_effort: self.model_reasoning_effort,
model_reasoning_summary: self.model_reasoning_summary,
user_instructions,
base_instructions: self.base_instructions.clone(),
approval_policy: self.approval_policy,
sandbox_policy: self.sandbox_policy.clone(),
disable_response_storage: self.disable_response_storage,
notify: self.notify.clone(),
cwd: self.cwd.clone(),
resume_path: self.experimental_resume.clone(),
}
}
}
/// Read `CODEX_HOME/config.toml` and return it as a generic TOML value. Returns

View File

@@ -32,7 +32,7 @@ pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi;
pub use model_provider_info::built_in_model_providers;
mod models;
mod openai_model_info;
pub mod openai_model_info;
mod openai_tools;
pub mod plan_tool;
mod project_doc;

View File

@@ -69,3 +69,8 @@ pub(crate) fn get_model_info(name: &str) -> Option<ModelInfo> {
_ => None,
}
}
/// Return a curated list of commonly-used OpenAI model names for selection UIs.
pub fn get_all_model_names() -> Vec<&'static str> {
vec!["codex-mini-latest", "o3", "o4-mini", "gpt-4.1", "gpt-4o"]
}

View File

@@ -14,8 +14,8 @@ path = "src/lib.rs"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
codex-common = { path = "../common" }
ignore = "0.4.23"
nucleo-matcher = "0.3.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.110"
tokio = { version = "1", features = ["full"] }

View File

@@ -1,14 +1,9 @@
use codex_common::fuzzy_match::fuzzy_indices as common_fuzzy_indices;
use codex_common::fuzzy_match::fuzzy_match as common_fuzzy_match;
use ignore::WalkBuilder;
use ignore::overrides::OverrideBuilder;
use nucleo_matcher::Matcher;
use nucleo_matcher::Utf32Str;
use nucleo_matcher::pattern::AtomKind;
use nucleo_matcher::pattern::CaseMatching;
use nucleo_matcher::pattern::Normalization;
use nucleo_matcher::pattern::Pattern;
use serde::Serialize;
use std::cell::UnsafeCell;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::num::NonZero;
use std::path::Path;
@@ -24,17 +19,13 @@ pub use cli::Cli;
/// A single match result returned from the search.
///
/// * `score` Relevance score returned by `nucleo_matcher`.
/// * `score` Relevance score from the fuzzy matcher (smaller is better).
/// * `path` Path to the matched file (relative to the search directory).
/// * `indices` Optional list of character indices that matched the query.
/// These are only filled when the caller of [`run`] sets
/// `compute_indices` to `true`. The indices vector follows the
/// guidance from `nucleo_matcher::Pattern::indices`: they are
/// unique and sorted in ascending order so that callers can use
/// them directly for highlighting.
/// * `indices` Optional list of character positions that matched the query.
/// These are unique and sorted so callers can use them directly for highlighting.
#[derive(Debug, Clone, Serialize)]
pub struct FileMatch {
pub score: u32,
pub score: i32,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub indices: Option<Vec<u32>>, // Sorted & deduplicated when present
@@ -130,7 +121,6 @@ pub fn run(
cancel_flag: Arc<AtomicBool>,
compute_indices: bool,
) -> anyhow::Result<FileSearchResults> {
let pattern = create_pattern(pattern_text);
// Create one BestMatchesList per worker thread so that each worker can
// operate independently. The results across threads will be merged when
// the traversal is complete.
@@ -139,13 +129,7 @@ pub fn run(
num_best_matches_lists,
} = create_worker_count(threads);
let best_matchers_per_worker: Vec<UnsafeCell<BestMatchesList>> = (0..num_best_matches_lists)
.map(|_| {
UnsafeCell::new(BestMatchesList::new(
limit.get(),
pattern.clone(),
Matcher::new(nucleo_matcher::Config::DEFAULT),
))
})
.map(|_| UnsafeCell::new(BestMatchesList::new(limit.get(), pattern_text.to_string())))
.collect();
// Use the same tree-walker library that ripgrep uses. We use it directly so
@@ -220,47 +204,33 @@ pub fn run(
}
// Merge results across best_matchers_per_worker.
let mut global_heap: BinaryHeap<Reverse<(u32, String)>> = BinaryHeap::new();
let mut global_heap: BinaryHeap<(i32, String)> = BinaryHeap::new();
let mut total_match_count = 0;
for best_list_cell in best_matchers_per_worker.iter() {
let best_list = unsafe { &*best_list_cell.get() };
total_match_count += best_list.num_matches;
for &Reverse((score, ref line)) in best_list.binary_heap.iter() {
for &(score, ref line) in best_list.binary_heap.iter() {
if global_heap.len() < limit.get() {
global_heap.push(Reverse((score, line.clone())));
} else if let Some(min_element) = global_heap.peek() {
if score > min_element.0.0 {
global_heap.push((score, line.clone()));
} else if let Some(&(worst_score, _)) = global_heap.peek() {
if score < worst_score {
global_heap.pop();
global_heap.push(Reverse((score, line.clone())));
global_heap.push((score, line.clone()));
}
}
}
}
let mut raw_matches: Vec<(u32, String)> = global_heap.into_iter().map(|r| r.0).collect();
let mut raw_matches: Vec<(i32, String)> = global_heap.into_iter().collect();
sort_matches(&mut raw_matches);
// Transform into `FileMatch`, optionally computing indices.
let mut matcher = if compute_indices {
Some(Matcher::new(nucleo_matcher::Config::DEFAULT))
} else {
None
};
let matches: Vec<FileMatch> = raw_matches
.into_iter()
.map(|(score, path)| {
let indices = if compute_indices {
let mut buf = Vec::<char>::new();
let haystack: Utf32Str<'_> = Utf32Str::new(&path, &mut buf);
let mut idx_vec: Vec<u32> = Vec::new();
if let Some(ref mut m) = matcher {
// Ignore the score returned from indices we already have `score`.
pattern.indices(haystack, m, &mut idx_vec);
}
idx_vec.sort_unstable();
idx_vec.dedup();
Some(idx_vec)
common_fuzzy_indices(&path, pattern_text)
.map(|v| v.into_iter().map(|i| i as u32).collect())
} else {
None
};
@@ -279,9 +249,9 @@ pub fn run(
})
}
/// Sort matches in-place by descending score, then ascending path.
fn sort_matches(matches: &mut [(u32, String)]) {
matches.sort_by(|a, b| match b.0.cmp(&a.0) {
/// Sort matches in-place by ascending score, then ascending path.
fn sort_matches(matches: &mut [(i32, String)]) {
matches.sort_by(|a, b| match a.0.cmp(&b.0) {
std::cmp::Ordering::Equal => a.1.cmp(&b.1),
other => other,
});
@@ -291,39 +261,31 @@ fn sort_matches(matches: &mut [(u32, String)]) {
struct BestMatchesList {
max_count: usize,
num_matches: usize,
pattern: Pattern,
matcher: Matcher,
binary_heap: BinaryHeap<Reverse<(u32, String)>>,
/// Internal buffer for converting strings to UTF-32.
utf32buf: Vec<char>,
pattern: String,
binary_heap: BinaryHeap<(i32, String)>,
}
impl BestMatchesList {
fn new(max_count: usize, pattern: Pattern, matcher: Matcher) -> Self {
fn new(max_count: usize, pattern: String) -> Self {
Self {
max_count,
num_matches: 0,
pattern,
matcher,
binary_heap: BinaryHeap::new(),
utf32buf: Vec::<char>::new(),
}
}
fn insert(&mut self, line: &str) {
let haystack: Utf32Str<'_> = Utf32Str::new(line, &mut self.utf32buf);
if let Some(score) = self.pattern.score(haystack, &mut self.matcher) {
// In the tests below, we verify that score() returns None for a
// non-match, so we can categorically increment the count here.
if let Some((_indices, score)) = common_fuzzy_match(line, &self.pattern) {
// Count all matches; non-matches return None above.
self.num_matches += 1;
if self.binary_heap.len() < self.max_count {
self.binary_heap.push(Reverse((score, line.to_string())));
} else if let Some(min_element) = self.binary_heap.peek() {
if score > min_element.0.0 {
self.binary_heap.push((score, line.to_string()));
} else if let Some(&(worst_score, _)) = self.binary_heap.peek() {
if score < worst_score {
self.binary_heap.pop();
self.binary_heap.push(Reverse((score, line.to_string())));
self.binary_heap.push((score, line.to_string()));
}
}
}
@@ -354,28 +316,16 @@ fn create_worker_count(num_workers: NonZero<usize>) -> WorkerCount {
}
}
fn create_pattern(pattern: &str) -> Pattern {
Pattern::new(
pattern,
CaseMatching::Smart,
Normalization::Smart,
AtomKind::Fuzzy,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_score_is_none_for_non_match() {
let mut utf32buf = Vec::<char>::new();
let line = "hello";
let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT);
let haystack: Utf32Str<'_> = Utf32Str::new(line, &mut utf32buf);
let pattern = create_pattern("zzz");
let score = pattern.score(haystack, &mut matcher);
assert_eq!(score, None);
fn verify_no_match_does_not_increment_or_push() {
let mut list = BestMatchesList::new(5, "zzz".to_string());
list.insert("hello");
assert_eq!(list.num_matches, 0);
assert_eq!(list.binary_heap.len(), 0);
}
#[test]
@@ -388,11 +338,11 @@ mod tests {
sort_matches(&mut matches);
// Highest score first; ties broken alphabetically.
// Lowest score first; ties broken alphabetically.
let expected = vec![
(90, "zzz".to_string()),
(100, "a_path".to_string()),
(100, "b_path".to_string()),
(90, "zzz".to_string()),
];
assert_eq!(matches, expected);

View File

@@ -57,6 +57,7 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
toml = "0.8"
tracing = { version = "0.1.41", features = ["log"] }
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

View File

@@ -1,6 +1,8 @@
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
use crate::danger_warning_screen::DangerWarningOutcome;
use crate::danger_warning_screen::DangerWarningScreen;
use crate::file_search::FileSearchManager;
use crate::get_git_diff::get_git_diff;
use crate::git_warning_screen::GitWarningOutcome;
@@ -16,9 +18,13 @@ use crossterm::SynchronizedUpdate;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::execute as ct_execute;
use crossterm::terminal::EnterAlternateScreen;
use crossterm::terminal::LeaveAlternateScreen;
use crossterm::terminal::supports_keyboard_enhancement;
use ratatui::backend::Backend;
use ratatui::layout::Offset;
use ratatui::prelude::Backend;
use ratatui::layout::Rect;
use ratatui::text::Line;
use std::path::PathBuf;
use std::sync::Arc;
@@ -43,8 +49,18 @@ enum AppState<'a> {
},
/// The start-up warning that recommends running codex inside a Git repo.
GitWarning { screen: GitWarningScreen },
/// Fullscreen warning when switching to the fullyunsafe execution mode.
DangerWarning {
screen: DangerWarningScreen,
/// Retain the chat widget so background events can still be processed.
widget: Box<ChatWidget<'a>>,
pending_approval: codex_core::protocol::AskForApproval,
pending_sandbox: codex_core::protocol::SandboxPolicy,
},
}
/// Strip a single pair of surrounding quotes from the provided string if present.
/// Supports straight and common curly quotes: '…', "…", ‘…’, “…”.
pub(crate) struct App<'a> {
app_event_tx: AppEventSender,
app_event_rx: Receiver<AppEvent>,
@@ -65,6 +81,16 @@ pub(crate) struct App<'a> {
chat_args: Option<ChatWidgetArgs>,
enhanced_keys_supported: bool,
/// One-shot flag to resync viewport and cursor after leaving the
/// alternate-screen Danger warning so the composer stays at the bottom.
fixup_viewport_after_danger: bool,
/// If set, defer opening the DangerWarning screen until after the next
/// redraw so any selection popups are cleared from the normal screen.
pending_show_danger: Option<(
codex_core::protocol::AskForApproval,
codex_core::protocol::SandboxPolicy,
)>,
last_bottom_pane_area: Option<Rect>,
}
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
@@ -75,14 +101,54 @@ struct ChatWidgetArgs {
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
cli_flags_used: Vec<String>,
cli_model: Option<String>,
}
impl App<'_> {
/// Handle `/model <arg>` from the slash command dispatcher.
fn handle_model_command(&mut self, args: &str) {
let arg = args.trim();
if let AppState::Chat { widget } = &mut self.app_state {
let normalized = crate::command_utils::normalize_token(arg);
if !normalized.is_empty() {
widget.update_model_and_reconfigure(normalized);
}
}
}
fn handle_approvals_command(&mut self, args: &str) {
let arg = args.trim();
if let AppState::Chat { widget } = &mut self.app_state {
let normalized = crate::command_utils::normalize_token(arg);
if !normalized.is_empty() {
use crate::command_utils::parse_execution_mode_token;
if let Some((approval, sandbox)) = parse_execution_mode_token(&normalized) {
if crate::command_utils::ExecutionPreset::from_policies(approval, &sandbox)
== Some(crate::command_utils::ExecutionPreset::FullYolo)
{
// Defer opening the danger screen until after the next redraw so any
// selection UI is cleared.
self.pending_show_danger = Some((approval, sandbox));
self.app_event_tx.send(AppEvent::RequestRedraw);
} else {
widget.update_execution_mode_and_reconfigure(approval, sandbox);
}
} else {
widget.add_diff_output(format!(
"`/approvals {normalized}` — unrecognized execution mode"
));
}
}
}
}
pub(crate) fn new(
config: Config,
initial_prompt: Option<String>,
show_git_warning: bool,
initial_images: Vec<std::path::PathBuf>,
cli_flags_used: Vec<String>,
cli_model: Option<String>,
) -> Self {
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
@@ -121,13 +187,9 @@ impl App<'_> {
let pasted = pasted.replace("\r", "\n");
app_event_tx.send(AppEvent::Paste(pasted));
}
_ => {
// Ignore any other events.
}
_ => {}
}
}
} else {
// Timeout expired, no `Event` is available
}
}
});
@@ -143,6 +205,8 @@ impl App<'_> {
initial_prompt,
initial_images,
enhanced_keys_supported,
cli_flags_used: cli_flags_used.clone(),
cli_model: cli_model.clone(),
}),
)
} else {
@@ -152,6 +216,8 @@ impl App<'_> {
initial_prompt,
initial_images,
enhanced_keys_supported,
cli_flags_used.clone(),
cli_model.clone(),
);
(
AppState::Chat {
@@ -172,6 +238,9 @@ impl App<'_> {
pending_redraw,
chat_args,
enhanced_keys_supported,
fixup_viewport_after_danger: false,
pending_show_danger: None,
last_bottom_pane_area: None,
}
}
@@ -219,6 +288,38 @@ impl App<'_> {
}
AppEvent::Redraw => {
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
if let Some((approval, sandbox)) = self.pending_show_danger.take() {
if let Some(area) = self.last_bottom_pane_area {
use crossterm::cursor::MoveTo;
use crossterm::queue;
use crossterm::terminal::Clear;
use crossterm::terminal::ClearType;
use std::io::Write;
for y in area.y..area.bottom() {
let _ = queue!(
std::io::stdout(),
MoveTo(0, y),
Clear(ClearType::CurrentLine)
);
}
let _ = std::io::stdout().flush();
}
if let AppState::Chat { widget } = std::mem::replace(
&mut self.app_state,
AppState::GitWarning {
screen: GitWarningScreen::new(),
},
) {
let _ = ct_execute!(std::io::stdout(), EnterAlternateScreen);
self.app_state = AppState::DangerWarning {
screen: DangerWarningScreen::new(),
widget,
pending_approval: approval,
pending_sandbox: sandbox,
};
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
}
AppEvent::KeyEvent(key_event) => {
match key_event {
@@ -227,47 +328,38 @@ impl App<'_> {
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
match &mut self.app_state {
AppState::Chat { widget } => {
widget.on_ctrl_c();
}
AppState::GitWarning { .. } => {
// No-op.
}
} => match &mut self.app_state {
AppState::Chat { widget } => {
widget.on_ctrl_c();
}
}
AppState::GitWarning { .. } => {}
AppState::DangerWarning { .. } => {}
},
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
match &mut self.app_state {
AppState::Chat { widget } => {
if widget.composer_is_empty() {
self.app_event_tx.send(AppEvent::ExitRequest);
} else {
// Treat Ctrl+D as a normal key event when the composer
// is not empty so that it doesn't quit the application
// prematurely.
self.dispatch_key_event(key_event);
}
}
AppState::GitWarning { .. } => {
} => match &mut self.app_state {
AppState::Chat { widget } => {
if widget.composer_is_empty() {
self.app_event_tx.send(AppEvent::ExitRequest);
} else {
self.dispatch_key_event(key_event);
}
}
}
AppState::GitWarning { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
AppState::DangerWarning { .. } => {}
},
KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
self.dispatch_key_event(key_event);
}
_ => {
// Ignore Release key events for now.
}
_ => {}
};
}
AppEvent::Paste(text) => {
@@ -279,36 +371,81 @@ impl App<'_> {
AppEvent::ExitRequest => {
break;
}
AppEvent::SelectModel(model) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.update_model_and_reconfigure(model);
}
}
AppEvent::SelectExecutionMode { approval, sandbox } => {
// Intercept the dangerous preset with a fullscreen warning.
if let AppState::Chat { widget: _ } = &self.app_state {
if crate::command_utils::ExecutionPreset::from_policies(approval, &sandbox)
== Some(crate::command_utils::ExecutionPreset::FullYolo)
{
// Defer opening the danger screen until after the next redraw so the
// selection popup is closed and the normal screen is clean.
self.pending_show_danger = Some((approval, sandbox));
self.app_event_tx.send(AppEvent::RequestRedraw);
} else if let AppState::Chat { widget } = std::mem::replace(
&mut self.app_state,
AppState::GitWarning {
screen: GitWarningScreen::new(),
},
) {
// Restore chat state and apply immediately for safe presets.
let mut w = widget;
w.update_execution_mode_and_reconfigure(approval, sandbox);
self.app_state = AppState::Chat { widget: w };
}
}
}
AppEvent::OpenModelSelector => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_model_selector();
}
}
AppEvent::OpenExecutionSelector => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_execution_selector();
}
}
AppEvent::CodexOp(op) => match &mut self.app_state {
AppState::Chat { widget } => widget.submit_op(op),
AppState::GitWarning { .. } => {}
AppState::DangerWarning { widget, .. } => widget.submit_op(op),
},
AppEvent::LatestLog(line) => match &mut self.app_state {
AppState::Chat { widget } => widget.update_latest_log(line),
AppState::GitWarning { .. } => {}
AppState::DangerWarning { widget, .. } => widget.update_latest_log(line),
},
AppEvent::DispatchCommand(command) => match command {
SlashCommand::New => {
AppEvent::DispatchCommand { cmd, args } => match (cmd, args.as_deref()) {
(SlashCommand::New, _) => {
let new_widget = Box::new(ChatWidget::new(
self.config.clone(),
self.app_event_tx.clone(),
None,
Vec::new(),
self.enhanced_keys_supported,
self.chat_args
.as_ref()
.map(|a| a.cli_flags_used.clone())
.unwrap_or_default(),
self.chat_args.as_ref().and_then(|a| a.cli_model.clone()),
));
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
SlashCommand::Compact => {
(SlashCommand::Compact, _) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.clear_token_usage();
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
}
}
SlashCommand::Quit => {
(SlashCommand::Quit, _) => {
break;
}
SlashCommand::Diff => {
(SlashCommand::Diff, _) => {
let (is_git_repo, diff_text) = match get_git_diff() {
Ok(v) => v,
Err(e) => {
@@ -330,7 +467,7 @@ impl App<'_> {
}
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
(SlashCommand::TestApproval, _) => {
use std::collections::HashMap;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
@@ -338,12 +475,6 @@ impl App<'_> {
self.app_event_tx.send(AppEvent::CodexEvent(Event {
id: "1".to_string(),
// msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
// call_id: "1".to_string(),
// command: vec!["git".into(), "apply".into()],
// cwd: self.config.cwd.clone(),
// reason: Some("test".to_string()),
// }),
msg: EventMsg::ApplyPatchApprovalRequest(
ApplyPatchApprovalRequestEvent {
call_id: "1".to_string(),
@@ -368,6 +499,15 @@ impl App<'_> {
),
}));
}
(SlashCommand::Model, Some(args)) => self.handle_model_command(args),
(SlashCommand::Approvals, Some(args)) => self.handle_approvals_command(args),
// 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);
@@ -388,6 +528,7 @@ impl App<'_> {
match &self.app_state {
AppState::Chat { widget } => widget.token_usage().clone(),
AppState::GitWarning { .. } => codex_core::protocol::TokenUsage::default(),
AppState::DangerWarning { widget, .. } => widget.token_usage().clone(),
}
}
@@ -416,8 +557,24 @@ impl App<'_> {
let desired_height = match &self.app_state {
AppState::Chat { widget } => widget.desired_height(size.width),
AppState::GitWarning { .. } => 10,
AppState::DangerWarning { .. } => size.height,
};
// After leaving the danger modal, resync cursor and bottomanchor the viewport.
if self.fixup_viewport_after_danger {
self.fixup_viewport_after_danger = false;
let pos = terminal.get_cursor_position()?;
terminal.last_known_cursor_pos = pos;
let old_area = terminal.viewport_area;
let mut new_area = old_area;
new_area.height = desired_height.min(size.height);
new_area.width = size.width;
new_area.y = size.height.saturating_sub(new_area.height);
if new_area != old_area {
terminal.set_viewport_area(new_area);
}
}
let mut area = terminal.viewport_area;
area.height = desired_height.min(size.height);
area.width = size.width;
@@ -443,9 +600,17 @@ impl App<'_> {
if let Some((x, y)) = widget.cursor_pos(frame.area()) {
frame.set_cursor_position((x, y));
}
frame.render_widget_ref(&**widget, frame.area())
frame.render_widget_ref(&**widget, frame.area());
self.last_bottom_pane_area = Some(area);
}
AppState::GitWarning { screen } => {
frame.render_widget_ref(&*screen, frame.area());
self.last_bottom_pane_area = None;
}
AppState::DangerWarning { screen, .. } => {
frame.render_widget_ref(&*screen, frame.area());
self.last_bottom_pane_area = None;
}
AppState::GitWarning { screen } => frame.render_widget_ref(&*screen, frame.area()),
})?;
Ok(())
}
@@ -471,6 +636,8 @@ impl App<'_> {
args.initial_prompt,
args.initial_images,
args.enhanced_keys_supported,
args.cli_flags_used,
args.cli_model,
));
self.app_state = AppState::Chat { widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
@@ -478,9 +645,47 @@ impl App<'_> {
GitWarningOutcome::Quit => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
GitWarningOutcome::None => {
// do nothing
GitWarningOutcome::None => {}
},
AppState::DangerWarning { screen, .. } => match screen.handle_key_event(key_event) {
DangerWarningOutcome::Continue => {
let taken = std::mem::replace(
&mut self.app_state,
AppState::GitWarning {
screen: GitWarningScreen::new(),
},
);
let _ = ct_execute!(std::io::stdout(), LeaveAlternateScreen);
self.fixup_viewport_after_danger = true;
if let AppState::DangerWarning {
mut widget,
pending_approval,
pending_sandbox,
..
} = taken
{
let approval = pending_approval;
let sandbox = pending_sandbox;
widget.update_execution_mode_and_reconfigure(approval, sandbox);
self.app_state = AppState::Chat { widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
DangerWarningOutcome::Cancel => {
let taken = std::mem::replace(
&mut self.app_state,
AppState::GitWarning {
screen: GitWarningScreen::new(),
},
);
let _ = ct_execute!(std::io::stdout(), LeaveAlternateScreen);
self.fixup_viewport_after_danger = true;
if let AppState::DangerWarning { widget, .. } = taken {
self.app_state = AppState::Chat { widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
DangerWarningOutcome::None => {}
},
}
}
@@ -489,6 +694,7 @@ impl App<'_> {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_paste(pasted),
AppState::GitWarning { .. } => {}
AppState::DangerWarning { .. } => {}
}
}
@@ -496,6 +702,48 @@ impl App<'_> {
match &mut self.app_state {
AppState::Chat { widget } => widget.handle_codex_event(event),
AppState::GitWarning { .. } => {}
AppState::DangerWarning { widget, .. } => widget.handle_codex_event(event),
}
}
}
#[cfg(test)]
mod tests {
use crate::command_utils::strip_surrounding_quotes;
#[test]
fn strip_surrounding_quotes_cases() {
let cases = vec![
("o3", "o3"),
(" \"codex-mini-latest\" ", "codex-mini-latest"),
("another_model", "another_model"),
("quoted", "quoted"),
("“smart”", "smart"),
];
for (input, expected) in cases {
assert_eq!(strip_surrounding_quotes(input), expected.to_string());
}
}
#[test]
fn model_command_args_extraction_and_normalization() {
let cases = vec![
("/model", "", ""),
("/model o3", "o3", "o3"),
("/model another_model", "another_model", "another_model"),
];
for (line, raw_expected, norm_expected) in cases {
let raw = if let Some(stripped) = line.strip_prefix('/') {
let token = stripped.trim_start();
let cmd_token = token.split_whitespace().next().unwrap_or("");
let rest = &token[cmd_token.len()..];
rest.trim_start().to_string()
} else {
String::new()
};
assert_eq!(raw, raw_expected, "raw args for '{line}'");
let normalized = strip_surrounding_quotes(&raw).trim().to_string();
assert_eq!(normalized, norm_expected, "normalized args for '{line}'");
}
}
}

View File

@@ -4,6 +4,8 @@ use crossterm::event::KeyEvent;
use ratatui::text::Line;
use crate::slash_command::SlashCommand;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
#[allow(clippy::large_enum_variant)]
pub(crate) enum AppEvent {
@@ -31,8 +33,12 @@ pub(crate) enum AppEvent {
LatestLog(String),
/// Dispatch a recognized slash command from the UI (composer) to the app
/// layer so it can be handled centrally.
DispatchCommand(SlashCommand),
/// layer so it can be handled centrally. Optional `args` contains the
/// left-trimmed raw argument string following the command, if any.
DispatchCommand {
cmd: SlashCommand,
args: Option<String>,
},
/// Kick off an asynchronous file search for the given query (text after
/// the `@`). Previous searches may be cancelled by the app layer so there
@@ -48,4 +54,19 @@ pub(crate) enum AppEvent {
},
InsertHistory(Vec<Line<'static>>),
/// User selected a model from the model-selection dropdown.
SelectModel(String),
/// Request the app to open the model selector (populate options and show popup).
OpenModelSelector,
/// User selected an execution mode (approval + sandbox) from the dropdown or via /approvals.
SelectExecutionMode {
approval: AskForApproval,
sandbox: SandboxPolicy,
},
/// Request the app to open the execution-mode selector (populate options and show popup).
OpenExecutionSelector,
}

View File

@@ -22,11 +22,18 @@ use ratatui::widgets::WidgetRef;
use super::chat_composer_history::ChatComposerHistory;
use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup;
use super::selection_popup::SelectionKind;
use super::selection_popup::SelectionPopup;
use super::selection_popup::SelectionValue;
use crate::command_utils::parse_execution_mode_token;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::textarea::TextArea;
use crate::bottom_pane::textarea::TextAreaState;
use crate::slash_command::ParsedSlash;
use crate::slash_command::SlashCommand;
use crate::slash_command::parse_slash_line;
use codex_file_search::FileMatch;
use std::cell::RefCell;
@@ -54,7 +61,7 @@ pub(crate) struct ChatComposer {
history: ChatComposerHistory,
ctrl_c_quit_hint: bool,
use_shift_enter_hint: bool,
dismissed_file_popup_token: Option<String>,
dismissed: Dismissed,
current_file_query: Option<String>,
pending_pastes: Vec<(String, String)>,
token_usage_info: Option<TokenUsageInfo>,
@@ -66,9 +73,31 @@ enum ActivePopup {
None,
Command(CommandPopup),
File(FileSearchPopup),
Selection(SelectionPopup),
}
/// Tracks tokens for which the user explicitly dismissed a popup to avoid
/// reopening it immediately unless the input changes meaningfully.
struct Dismissed {
slash: Option<String>,
file: Option<String>,
}
impl ChatComposer {
#[inline]
fn first_line(&self) -> &str {
self.textarea.text().lines().next().unwrap_or("")
}
#[inline]
fn sync_popups(&mut self) {
self.sync_command_popup();
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.dismissed.file = None;
} else {
self.sync_file_search_popup();
}
}
pub fn new(
has_input_focus: bool,
app_event_tx: AppEventSender,
@@ -84,7 +113,10 @@ impl ChatComposer {
history: ChatComposerHistory::new(),
ctrl_c_quit_hint: false,
use_shift_enter_hint,
dismissed_file_popup_token: None,
dismissed: Dismissed {
slash: None,
file: None,
},
current_file_query: None,
pending_pastes: Vec::new(),
token_usage_info: None,
@@ -98,6 +130,7 @@ impl ChatComposer {
ActivePopup::None => 1u16,
ActivePopup::Command(c) => c.calculate_required_height(),
ActivePopup::File(c) => c.calculate_required_height(),
ActivePopup::Selection(c) => c.calculate_required_height(),
}
}
@@ -105,6 +138,7 @@ impl ChatComposer {
let popup_height = match &self.active_popup {
ActivePopup::Command(popup) => popup.calculate_required_height(),
ActivePopup::File(popup) => popup.calculate_required_height(),
ActivePopup::Selection(popup) => popup.calculate_required_height(),
ActivePopup::None => 1,
};
let [textarea_rect, _] =
@@ -167,8 +201,7 @@ impl ChatComposer {
} else {
self.textarea.insert_str(&pasted);
}
self.sync_command_popup();
self.sync_file_search_popup();
self.sync_popups();
true
}
@@ -194,20 +227,79 @@ impl ChatComposer {
self.set_has_focus(has_focus);
}
/// Open or update the model-selection popup with the provided options.
pub(crate) fn open_model_selector(&mut self, current_model: &str, options: Vec<String>) {
match &mut self.active_popup {
ActivePopup::Selection(popup) if popup.kind() == SelectionKind::Model => {
*popup = SelectionPopup::new_model(current_model, options);
}
_ => {
self.active_popup =
ActivePopup::Selection(SelectionPopup::new_model(current_model, options));
}
}
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);
}
}
}
}
/// Open or update the execution-mode selection popup with the provided options.
pub(crate) fn open_execution_selector(
&mut self,
current_approval: codex_core::protocol::AskForApproval,
current_sandbox: &codex_core::protocol::SandboxPolicy,
) {
match &mut self.active_popup {
ActivePopup::Selection(popup) if popup.kind() == SelectionKind::Execution => {
*popup = SelectionPopup::new_execution_modes(current_approval, current_sandbox);
}
_ => {
self.active_popup = ActivePopup::Selection(SelectionPopup::new_execution_modes(
current_approval,
current_sandbox,
));
}
}
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);
}
}
}
}
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let result = match &mut self.active_popup {
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
ActivePopup::Selection(_) => self.handle_key_event_with_selection_popup(key_event),
ActivePopup::None => self.handle_key_event_without_popup(key_event),
};
// Update (or hide/show) popup after processing the key.
self.sync_command_popup();
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.dismissed_file_popup_token = None;
} else {
self.sync_file_search_popup();
match &self.active_popup {
ActivePopup::Selection(_) => {
self.sync_selection_popup();
}
ActivePopup::Command(_) => {
self.sync_command_popup();
// When slash popup active, suppress file popup.
self.dismissed.file = None;
}
_ => {
self.sync_command_popup();
if !matches!(self.active_popup, ActivePopup::Command(_)) {
self.sync_file_search_popup();
}
}
}
result
@@ -215,6 +307,7 @@ impl ChatComposer {
/// Handle key event when the slash-command popup is visible.
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let first_line_owned = self.first_line().to_string();
let ActivePopup::Command(popup) = &mut self.active_popup else {
unreachable!();
};
@@ -233,12 +326,26 @@ impl ChatComposer {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
// 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)
}
KeyEvent {
code: KeyCode::Tab, ..
} => {
if let Some(cmd) = popup.selected_command() {
let first_line = self.textarea.text().lines().next().unwrap_or("");
let starts_with_cmd = first_line
.trim_start()
.starts_with(&format!("/{}", cmd.command()));
@@ -255,18 +362,44 @@ impl ChatComposer {
..
} => {
if let Some(cmd) = popup.selected_command() {
// Send command to the app layer.
self.app_event_tx.send(AppEvent::DispatchCommand(*cmd));
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,
};
// Clear textarea so no residual text remains.
self.app_event_tx.send(AppEvent::DispatchCommand {
cmd: *cmd,
args: args_opt,
});
self.textarea.set_text("");
// Hide popup since the command has been dispatched.
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
// Fallback to default newline handling if no command selected.
self.handle_key_event_without_popup(key_event)
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(),
};
self.dismissed.slash = Some(invalid_token.clone());
self.active_popup = ActivePopup::None;
{
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, true)
}
input => self.handle_input_basic(input),
}
@@ -274,6 +407,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!();
};
@@ -295,9 +429,8 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Esc, ..
} => {
// Hide popup without modifying text, remember token to avoid immediate reopen.
if let Some(tok) = Self::current_at_token(&self.textarea) {
self.dismissed_file_popup_token = Some(tok.to_string());
self.dismissed.file = Some(tok.to_string());
}
self.active_popup = ActivePopup::None;
(InputResult::None, true)
@@ -312,7 +445,6 @@ impl ChatComposer {
} => {
if let Some(sel) = popup.selected_match() {
let sel_path = sel.to_string();
// Drop popup borrow before using self mutably again.
self.insert_selected_path(&sel_path);
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
@@ -323,6 +455,97 @@ impl ChatComposer {
}
}
/// Handle key events when model selection popup is visible.
fn handle_key_event_with_selection_popup(
&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!();
};
match key_event {
KeyEvent {
code: KeyCode::Up, ..
} => {
popup.move_up();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Down,
..
} => {
popup.move_down();
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
code: KeyCode::Tab, ..
} => {
if let Some(value) = popup.selected_value() {
match value {
SelectionValue::Model(m) => {
self.app_event_tx.send(AppEvent::SelectModel(m))
}
SelectionValue::Execution { approval, sandbox } => self
.app_event_tx
.send(AppEvent::SelectExecutionMode { approval, sandbox }),
}
// Clear composer input and close the popup.
self.textarea.set_text("");
self.pending_pastes.clear();
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
// No selection in the list: attempt to parse typed arguments for the appropriate kind.
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 == SlashCommand::Model => {
self.app_event_tx.send(AppEvent::DispatchCommand {
cmd: SlashCommand::Model,
args: Some(args),
});
self.textarea.set_text("");
self.pending_pastes.clear();
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
SelectionKind::Execution if cmd == SlashCommand::Approvals => {
if let Some((approval, sandbox)) = parse_execution_mode_token(&args)
{
self.app_event_tx
.send(AppEvent::SelectExecutionMode { approval, sandbox });
self.textarea.set_text("");
self.pending_pastes.clear();
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
}
_ => {}
}
}
}
(InputResult::None, false)
}
input => self.handle_input_basic(input),
}
}
// Approval-specific handler removed; unified selection handler is used.
/// Extract the `@token` that the cursor is currently positioned on, if any.
///
/// The returned string **does not** include the leading `@`.
@@ -444,7 +667,6 @@ impl ChatComposer {
.unwrap_or(after_cursor.len());
let end_idx = cursor_offset + end_rel_idx;
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
let mut new_text =
String::with_capacity(text.len() - (end_idx - start_idx) + path.len() + 1);
new_text.push_str(&text[..start_idx]);
@@ -458,11 +680,6 @@ impl ChatComposer {
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
match key_event {
// -------------------------------------------------------------
// History navigation (Up / Down) only when the composer is not
// empty or when the cursor is at the correct position, to avoid
// interfering with normal cursor movement.
// -------------------------------------------------------------
KeyEvent {
code: KeyCode::Up | KeyCode::Down,
..
@@ -492,7 +709,6 @@ impl ChatComposer {
let mut text = self.textarea.text().to_string();
self.textarea.set_text("");
// Replace all pending pastes in the text
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
@@ -513,7 +729,6 @@ impl ChatComposer {
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// Special handling for backspace on placeholders
if let KeyEvent {
code: KeyCode::Backspace,
..
@@ -523,12 +738,8 @@ impl ChatComposer {
return (InputResult::None, true);
}
}
// Normal input handling
self.textarea.input(input);
let text_after = self.textarea.text();
// Check if any placeholders were removed and remove their corresponding pending pastes
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
@@ -563,22 +774,37 @@ impl ChatComposer {
}
}
/// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
fn sync_command_popup(&mut self) {
let first_line = self.textarea.text().lines().next().unwrap_or("");
let input_starts_with_slash = first_line.starts_with('/');
if !input_starts_with_slash {
self.dismissed.slash = None;
}
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 {
popup.on_composer_text_change(first_line.to_string());
} else {
self.active_popup = ActivePopup::None;
self.dismissed.slash = None;
}
}
_ => {
if input_starts_with_slash {
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.to_string());
self.active_popup = ActivePopup::Command(command_popup);
@@ -587,21 +813,16 @@ impl ChatComposer {
}
}
/// Synchronize `self.file_search_popup` with the current text in the textarea.
/// Note this is only called when self.active_popup is NOT Command.
fn sync_file_search_popup(&mut self) {
// Determine if there is an @token underneath the cursor.
let query = match Self::current_at_token(&self.textarea) {
Some(token) => token,
None => {
self.active_popup = ActivePopup::None;
self.dismissed_file_popup_token = None;
self.dismissed.file = None;
return;
}
};
// If user dismissed popup for this exact query, don't reopen until text changes.
if self.dismissed_file_popup_token.as_ref() == Some(&query) {
if self.dismissed.file.as_ref() == Some(&query) {
return;
}
@@ -620,7 +841,26 @@ impl ChatComposer {
}
self.current_file_query = Some(query);
self.dismissed_file_popup_token = None;
self.dismissed.file = None;
}
fn sync_selection_popup(&mut self) {
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),
_ => {
popup.set_query(first_line_owned.trim());
}
},
(ActivePopup::Selection(popup), _no_slash_cmd) => {
popup.set_query(first_line_owned.trim());
}
_ => {}
}
}
fn set_has_focus(&mut self, has_focus: bool) {
@@ -633,6 +873,7 @@ impl WidgetRef for &ChatComposer {
let popup_height = match &self.active_popup {
ActivePopup::Command(popup) => popup.calculate_required_height(),
ActivePopup::File(popup) => popup.calculate_required_height(),
ActivePopup::Selection(popup) => popup.calculate_required_height(),
ActivePopup::None => 1,
};
let [textarea_rect, popup_rect] =
@@ -644,6 +885,9 @@ impl WidgetRef for &ChatComposer {
ActivePopup::File(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::Selection(popup) => {
popup.render_ref(popup_rect, buf);
}
ActivePopup::None => {
let bottom_line_rect = popup_rect;
let key_hint_style = Style::default().fg(Color::Cyan);
@@ -729,6 +973,7 @@ impl WidgetRef for &ChatComposer {
#[cfg(test)]
mod tests {
use crate::bottom_pane::AppEventSender;
use crate::bottom_pane::ChatComposer;
use crate::bottom_pane::InputResult;
@@ -962,7 +1207,12 @@ mod tests {
let sender = AppEventSender::new(tx);
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
Err(e) => {
// Avoid printing directly to stderr/stdout (clippy::print_stderr).
// Log a warning instead and skip the snapshot test.
tracing::warn!("Skipping ui_snapshots: failed to create terminal: {e}");
return;
}
};
let test_cases = vec![
@@ -996,14 +1246,248 @@ mod tests {
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
}
terminal
.draw(|f| f.render_widget_ref(&composer, f.area()))
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
let draw_res = terminal.draw(|f| f.render_widget_ref(&composer, f.area()));
assert!(draw_res.is_ok(), "Failed to draw {name} composer");
assert_snapshot!(name, terminal.backend());
}
}
#[test]
fn esc_dismiss_slash_popup_reopen_on_token_change() {
use crate::bottom_pane::chat_composer::ActivePopup;
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);
composer.handle_paste("/".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::None));
composer.handle_paste("c".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
}
#[test]
fn esc_dismiss_then_delete_and_retype_slash_reopens_popup() {
use crate::bottom_pane::chat_composer::ActivePopup;
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);
composer.handle_paste("/".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::None));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::None));
composer.handle_paste("/".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
}
// removed tests tied to auto-opening selectors and composer-owned error messages
#[test]
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;
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("/zzz".to_string());
assert!(matches!(composer.active_popup, ActivePopup::Command(_)));
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));
// We should receive an InsertHistory with an error message.
let mut saw_error = false;
loop {
match rx.try_recv() {
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_error, "expected an error InsertHistory entry");
}
#[test]
fn enter_on_model_selector_selects_current_row() {
use crate::app_event::AppEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::sync::mpsc::TryRecvError;
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Open the model selector directly with a few options and a current model.
let options = vec![
"codex-mini-latest".to_string(),
"o3".to_string(),
"gpt-4o".to_string(),
];
composer.open_model_selector("o3", options);
// Press Enter to select the currently highlighted row (should default to first visible).
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// We should receive a SelectModel event.
let mut saw_select = false;
loop {
match rx.try_recv() {
Ok(AppEvent::SelectModel(_m)) => {
saw_select = true;
break;
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert!(
saw_select,
"Enter on model selector should emit SelectModel"
);
}
#[test]
fn model_selector_stays_open_on_up_down() {
use crate::bottom_pane::chat_composer::ActivePopup;
use crossterm::event::KeyCode;
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);
let options = vec![
"codex-mini-latest".to_string(),
"o3".to_string(),
"gpt-4o".to_string(),
];
composer.open_model_selector("o3", options);
// Press Down; popup should remain visible
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::Selection(_)));
// Press Up; popup should remain visible
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert!(matches!(composer.active_popup, ActivePopup::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;
@@ -1014,18 +1498,15 @@ mod tests {
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Define test cases: (paste content, is_large)
let test_cases = [
("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
(" and ".to_string(), false),
("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
];
// Expected states after each paste
let mut expected_text = String::new();
let mut expected_pending_count = 0;
// Apply all pastes and build expected state
let states: Vec<_> = test_cases
.iter()
.map(|(content, is_large)| {
@@ -1041,7 +1522,6 @@ mod tests {
})
.collect();
// Verify all intermediate states were correct
assert_eq!(
states,
vec![
@@ -1067,7 +1547,6 @@ mod tests {
]
);
// Submit and verify final expansion
let (result, _) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
if let InputResult::Submitted(text) = result {
@@ -1077,6 +1556,54 @@ mod tests {
}
}
// Note: slash command with args is usually handled via the selection popup.
#[test]
fn approvals_selection_full_yolo_emits_select_execution_mode() {
use crate::app_event::AppEvent;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::sync::mpsc::TryRecvError;
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Open the execution selector popup with a benign current mode.
composer.open_execution_selector(
AskForApproval::OnFailure,
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
include_default_writable_roots: true,
},
);
// Immediately move selection up once to wrap to the last item (Full yolo), then Enter.
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Expect a SelectExecutionMode with DangerFullAccess.
let mut saw = false;
loop {
match rx.try_recv() {
Ok(AppEvent::SelectExecutionMode { approval, sandbox }) => {
assert_eq!(approval, AskForApproval::Never);
assert!(matches!(sandbox, SandboxPolicy::DangerFullAccess));
saw = true;
break;
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert!(saw, "expected SelectExecutionMode for Full yolo");
}
#[test]
fn test_placeholder_deletion() {
use crossterm::event::KeyCode;
@@ -1087,14 +1614,12 @@ mod tests {
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Define test cases: (content, is_large)
let test_cases = [
("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
(" and ".to_string(), false),
("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
];
// Apply all pastes
let mut current_pos = 0;
let states: Vec<_> = test_cases
.iter()
@@ -1114,7 +1639,6 @@ mod tests {
})
.collect();
// Delete placeholders one by one and collect states
let mut deletion_states = vec![];
// First deletion
@@ -1133,7 +1657,6 @@ mod tests {
composer.pending_pastes.len(),
));
// Verify all states
assert_eq!(
deletion_states,
vec![
@@ -1153,7 +1676,6 @@ mod tests {
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Define test cases: (cursor_position_from_end, expected_pending_count)
let test_cases = [
5, // Delete from middle - should clear tracking
0, // Delete from end - should clear tracking
@@ -1187,4 +1709,45 @@ mod tests {
]
);
}
// removed test tied to composer opening approvals selector
#[test]
fn enter_on_approvals_selector_selects_current_row() {
use crate::app_event::AppEvent;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use std::sync::mpsc::TryRecvError;
let (tx, rx) = std::sync::mpsc::channel();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
// Open the execution selector directly (current: Read only)
composer.open_execution_selector(AskForApproval::Never, &SandboxPolicy::ReadOnly);
// Press Enter to select the currently highlighted row (first visible)
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// We should receive a SelectExecutionMode event.
let mut saw_select = false;
loop {
match rx.try_recv() {
Ok(AppEvent::SelectExecutionMode { .. }) => {
saw_select = true;
break;
}
Ok(_) => continue,
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
assert!(
saw_select,
"Enter on approvals selector should emit SelectApprovalPolicy"
);
}
}

View File

@@ -1,30 +1,21 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::symbols::border::QUADRANT_LEFT_HALF;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Cell;
use ratatui::widgets::Row;
use ratatui::widgets::Table;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
const MAX_POPUP_ROWS: usize = 5;
/// Ideally this is enough to show the longest command name.
const FIRST_COLUMN_WIDTH: u16 = 20;
use super::popup_consts::MAX_POPUP_ROWS;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use ratatui::style::Modifier;
use super::scroll_state::ScrollState;
use codex_common::fuzzy_match::fuzzy_match;
pub(crate) struct CommandPopup {
command_filter: String,
all_commands: Vec<(&'static str, SlashCommand)>,
selected_idx: Option<usize>,
state: ScrollState,
}
impl CommandPopup {
@@ -32,7 +23,7 @@ impl CommandPopup {
Self {
command_filter: String::new(),
all_commands: built_in_slash_commands(),
selected_idx: None,
state: ScrollState::new(),
}
}
@@ -62,129 +53,152 @@ impl CommandPopup {
// Reset or clamp selected index based on new filtered list.
let matches_len = self.filtered_commands().len();
self.selected_idx = match matches_len {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
};
self.state.clamp_selection(matches_len);
self.state
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Determine the preferred height of the popup. This is the number of
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
/// table/border overhead (one line at the top and one at the bottom).
/// rows required to show at most MAX_POPUP_ROWS commands.
pub(crate) fn calculate_required_height(&self) -> u16 {
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
}
/// Return the list of commands that match the current filter. Matching is
/// performed using a *prefix* comparison on the command name.
fn filtered_commands(&self) -> Vec<&SlashCommand> {
self.all_commands
.iter()
.filter_map(|(_name, cmd)| {
if self.command_filter.is_empty()
|| cmd
.command()
.starts_with(&self.command_filter.to_ascii_lowercase())
{
Some(cmd)
} else {
None
/// Compute fuzzy-filtered matches paired with optional highlight indices and score.
/// Sorted by ascending score, then by command name for stability.
fn filtered(&self) -> Vec<(&SlashCommand, Option<Vec<usize>>, i32)> {
let filter = self.command_filter.trim();
let mut out: Vec<(&SlashCommand, Option<Vec<usize>>, i32)> = Vec::new();
if filter.is_empty() {
for (_, cmd) in self.all_commands.iter() {
out.push((cmd, None, 0));
}
} else {
for (_, cmd) in self.all_commands.iter() {
if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
out.push((cmd, Some(indices), score));
}
})
.collect::<Vec<&SlashCommand>>()
}
}
out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.command().cmp(b.0.command())));
out
}
/// Backwards-compatible helper used by tests.
fn filtered_commands(&self) -> Vec<&SlashCommand> {
self.filtered().into_iter().map(|(c, _, _)| c).collect()
}
/// Move the selection cursor one step up.
pub(crate) fn move_up(&mut self) {
if let Some(len) = self.filtered_commands().len().checked_sub(1) {
if len == usize::MAX {
return;
}
}
if let Some(idx) = self.selected_idx {
if idx > 0 {
self.selected_idx = Some(idx - 1);
}
} else if !self.filtered_commands().is_empty() {
self.selected_idx = Some(0);
}
let matches = self.filtered_commands();
let len = matches.len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
}
/// Move the selection cursor one step down.
pub(crate) fn move_down(&mut self) {
let matches_len = self.filtered_commands().len();
if matches_len == 0 {
self.selected_idx = None;
return;
}
match self.selected_idx {
Some(idx) if idx + 1 < matches_len => {
self.selected_idx = Some(idx + 1);
}
None => {
self.selected_idx = Some(0);
}
_ => {}
}
let matches = self.filtered_commands();
let matches_len = matches.len();
self.state.move_down_wrap(matches_len);
self.state
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
}
/// Return currently selected command, if any.
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
let matches = self.filtered_commands();
self.selected_idx.and_then(|idx| matches.get(idx).copied())
self.state
.selected_idx
.and_then(|idx| matches.get(idx).copied())
}
}
impl WidgetRef for CommandPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let matches = self.filtered_commands();
let mut rows: Vec<Row> = Vec::new();
let visible_matches: Vec<&SlashCommand> =
matches.into_iter().take(MAX_POPUP_ROWS).collect();
if visible_matches.is_empty() {
rows.push(Row::new(vec![
Cell::from(""),
Cell::from("No matching commands").add_modifier(Modifier::ITALIC),
]));
let matches = self.filtered();
let rows_all: Vec<GenericDisplayRow> = if matches.is_empty() {
Vec::new()
} else {
let default_style = Style::default();
let command_style = Style::default().fg(Color::LightBlue);
for (idx, cmd) in visible_matches.iter().enumerate() {
rows.push(Row::new(vec![
Cell::from(Line::from(vec![
if Some(idx) == self.selected_idx {
Span::styled(
"",
Style::default().bg(Color::DarkGray).fg(Color::LightCyan),
)
} else {
Span::styled(QUADRANT_LEFT_HALF, Style::default().fg(Color::DarkGray))
},
Span::styled(format!("/{}", cmd.command()), command_style),
])),
Cell::from(cmd.description().to_string()).style(default_style),
]));
matches
.into_iter()
.map(|(cmd, indices, _)| GenericDisplayRow {
name: format!("/{}", cmd.command()),
match_indices: indices.map(|v| {
// Shift highlight indices by one to account for the leading '/'
v.into_iter().map(|i| i + 1).collect()
}),
is_current: false,
description: Some(cmd.description().to_string()),
})
.collect()
};
render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
#[test]
fn move_down_wraps_to_top() {
let mut popup = CommandPopup::new();
// Show all commands by simulating composer input starting with '/'.
popup.on_composer_text_change("/".to_string());
let len = popup.filtered_commands().len();
assert!(len > 0);
// Move to last item.
for _ in 0..len.saturating_sub(1) {
popup.move_down();
}
// Next move_down should wrap to index 0.
popup.move_down();
assert_eq!(popup.state.selected_idx, Some(0));
}
#[test]
fn move_up_wraps_to_bottom() {
let mut popup = CommandPopup::new();
popup.on_composer_text_change("/".to_string());
let len = popup.filtered_commands().len();
assert!(len > 0);
// Initial selection is 0; moving up should wrap to last.
popup.move_up();
assert_eq!(popup.state.selected_idx, Some(len - 1));
}
#[test]
fn respects_tiny_terminal_height_when_rendering() {
let mut popup = CommandPopup::new();
popup.on_composer_text_change("/".to_string());
assert!(popup.filtered_commands().len() >= 3);
let area = Rect::new(0, 0, 50, 2);
let mut buf = Buffer::empty(area);
popup.render(area, &mut buf);
let mut non_empty_rows = 0u16;
for y in 0..area.height {
let mut row_has_content = false;
for x in 0..area.width {
let c = buf[(x, y)].symbol();
if !c.trim().is_empty() {
row_has_content = true;
break;
}
}
if row_has_content {
non_empty_rows += 1;
}
}
use ratatui::layout::Constraint;
let table = Table::new(
rows,
[Constraint::Length(FIRST_COLUMN_WIDTH), Constraint::Min(10)],
)
.column_spacing(0);
// .block(
// Block::default()
// .borders(Borders::LEFT)
// .border_type(BorderType::QuadrantOutside)
// .border_style(Style::default().fg(Color::DarkGray)),
// );
table.render(area, buf);
assert_eq!(non_empty_rows, 2);
}
}

View File

@@ -1,23 +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;
/// Maximum number of suggestions shown in the popup.
const MAX_RESULTS: usize = 8;
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 {
@@ -30,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 {
@@ -41,7 +30,7 @@ impl FileSearchPopup {
pending_query: String::new(),
waiting: true,
matches: Vec::new(),
selected_idx: None,
state: ScrollState::new(),
}
}
@@ -61,7 +50,7 @@ impl FileSearchPopup {
if !keep_existing {
self.matches.clear();
self.selected_idx = None;
self.state.reset();
}
}
@@ -75,40 +64,32 @@ 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())
}
/// Preferred height (rows) including border.
pub(crate) fn calculate_required_height(&self) -> u16 {
// Row count depends on whether we already have matches. If no matches
// yet (e.g. initial search or query with no results) reserve a single
@@ -116,71 +97,35 @@ impl FileSearchPopup {
// up to MAX_RESULTS regardless of the waiting flag so the list
// remains stable while a newer search is in-flight.
self.matches.len().clamp(1, MAX_RESULTS) as u16
self.matches.len().clamp(1, MAX_POPUP_ROWS) as u16
}
}
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_RESULTS)
.enumerate()
.map(|(i, file_match)| {
let FileMatch { path, indices, .. } = file_match;
let path = path.as_str();
#[allow(clippy::expect_used)]
let indices = indices.as_ref().expect("indices should be present");
// Build spans with bold on matching indices.
let mut idx_iter = indices.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 idx_iter
.peek()
.is_some_and(|next| **next == char_idx as u32)
{
idx_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

@@ -18,6 +18,11 @@ mod chat_composer;
mod chat_composer_history;
mod command_popup;
mod file_search_popup;
mod popup_consts;
mod scroll_state;
mod selection_list;
pub(crate) mod selection_popup;
mod selection_popup_common;
mod status_indicator_view;
mod textarea;
@@ -31,6 +36,7 @@ pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
use approval_modal_view::ApprovalModalView;
use codex_core::protocol::AskForApproval;
use status_indicator_view::StatusIndicatorView;
/// Pane displayed in the lower half of the chat UI.
@@ -71,6 +77,23 @@ impl BottomPane<'_> {
}
}
/// Show the model-selection popup in the composer.
pub(crate) fn show_model_selector(&mut self, current_model: &str, options: Vec<String>) {
self.composer.open_model_selector(current_model, options);
self.request_redraw();
}
/// Show the execution-mode selection popup in the composer.
pub(crate) fn show_execution_selector(
&mut self,
current_approval: AskForApproval,
current_sandbox: &codex_core::protocol::SandboxPolicy,
) {
self.composer
.open_execution_selector(current_approval, current_sandbox);
self.request_redraw();
}
pub fn desired_height(&self, width: u16) -> u16 {
self.active_view
.as_ref()
@@ -156,9 +179,7 @@ impl BottomPane<'_> {
ConditionalUpdate::NeedsRedraw => {
self.request_redraw();
}
ConditionalUpdate::NoRedraw => {
// No redraw needed.
}
ConditionalUpdate::NoRedraw => {}
}
}
}
@@ -188,7 +209,6 @@ impl BottomPane<'_> {
match (running, self.active_view.is_some()) {
(true, false) => {
// Show status indicator overlay.
self.active_view = Some(Box::new(StatusIndicatorView::new(
self.app_event_tx.clone(),
)));
@@ -197,17 +217,13 @@ impl BottomPane<'_> {
(false, true) => {
if let Some(mut view) = self.active_view.take() {
if view.should_hide_when_task_is_done() {
// Leave self.active_view as None.
self.request_redraw();
} else {
// Preserve the view.
self.active_view = Some(view);
}
}
}
_ => {
// No change.
}
_ => {}
}
}
@@ -285,7 +301,6 @@ impl BottomPane<'_> {
impl WidgetRef for &BottomPane<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Show BottomPaneView if present.
if let Some(ov) = &self.active_view {
ov.render(area, buf);
} else {

View File

@@ -0,0 +1,5 @@
//! Shared popup-related constants for bottom pane widgets.
/// Maximum number of rows any popup should attempt to display.
/// Keep this consistent across all popups for a uniform feel.
pub(crate) const MAX_POPUP_ROWS: usize = 8;

View File

@@ -0,0 +1,115 @@
/// Generic scroll/selection state for a vertical list menu.
///
/// Encapsulates the common behavior of a selectable list that supports:
/// - Optional selection (None when list is empty)
/// - Wrap-around navigation on Up/Down
/// - Maintaining a scroll window (`scroll_top`) so the selected row stays visible
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ScrollState {
pub selected_idx: Option<usize>,
pub scroll_top: usize,
}
impl ScrollState {
pub fn new() -> Self {
Self {
selected_idx: None,
scroll_top: 0,
}
}
/// Reset selection and scroll.
pub fn reset(&mut self) {
self.selected_idx = None;
self.scroll_top = 0;
}
/// Clamp selection to be within the [0, len-1] range, or None when empty.
pub fn clamp_selection(&mut self, len: usize) {
self.selected_idx = match len {
0 => None,
_ => Some(self.selected_idx.unwrap_or(0).min(len - 1)),
};
if len == 0 {
self.scroll_top = 0;
}
}
/// Move selection up by one, wrapping to the bottom when necessary.
pub fn move_up_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
self.selected_idx = Some(match self.selected_idx {
Some(idx) if idx > 0 => idx - 1,
Some(_) => len - 1,
None => 0,
});
}
/// Move selection down by one, wrapping to the top when necessary.
pub fn move_down_wrap(&mut self, len: usize) {
if len == 0 {
self.selected_idx = None;
self.scroll_top = 0;
return;
}
self.selected_idx = Some(match self.selected_idx {
Some(idx) if idx + 1 < len => idx + 1,
_ => 0,
});
}
/// Adjust `scroll_top` so that the current `selected_idx` is visible within
/// the window of `visible_rows`.
pub fn ensure_visible(&mut self, len: usize, visible_rows: usize) {
if len == 0 || visible_rows == 0 {
self.scroll_top = 0;
return;
}
if let Some(sel) = self.selected_idx {
if sel < self.scroll_top {
self.scroll_top = sel;
} else {
let bottom = self.scroll_top + visible_rows - 1;
if sel > bottom {
self.scroll_top = sel + 1 - visible_rows;
}
}
} else {
self.scroll_top = 0;
}
}
}
#[cfg(test)]
mod tests {
use super::ScrollState;
#[test]
fn wrap_navigation_and_visibility() {
let mut s = ScrollState::new();
let len = 10;
let vis = 5;
s.clamp_selection(len);
assert_eq!(s.selected_idx, Some(0));
s.ensure_visible(len, vis);
assert_eq!(s.scroll_top, 0);
s.move_up_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(len - 1));
match s.selected_idx {
Some(sel) => assert!(s.scroll_top <= sel),
None => panic!("expected Some(selected_idx) after wrap"),
}
s.move_down_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(0));
assert_eq!(s.scroll_top, 0);
}
}

View File

@@ -0,0 +1,204 @@
//! Generic selection-list abstraction shared by model/execution selectors and other popups.
//!
//! This module provides `SelectionItem` (a value with name/description/aliases),
//! and `SelectionList` which maintains filtering and scroll/selection state.
//! The UI layer can convert items to `GenericDisplayRow` for rendering via
//! `selection_popup_common::render_rows`.
use codex_common::fuzzy_match::fuzzy_match;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
/// One selectable item in a generic selection list.
#[derive(Clone)]
pub(crate) struct SelectionItem<T> {
pub value: T,
pub name: String,
pub description: Option<String>,
pub aliases: Vec<String>,
pub is_current: bool,
}
impl<T> SelectionItem<T> {
pub fn new(value: T, name: String) -> Self {
Self {
value,
name,
description: None,
aliases: Vec::new(),
is_current: false,
}
}
pub fn with_description(mut self, desc: Option<String>) -> Self {
self.description = desc;
self
}
pub fn with_aliases(mut self, aliases: Vec<String>) -> Self {
self.aliases = aliases;
self
}
pub fn mark_current(mut self, is_current: bool) -> Self {
self.is_current = is_current;
self
}
}
/// Generic selection list state and fuzzy filtering.
pub(crate) struct SelectionList<T> {
items: Vec<SelectionItem<T>>,
query: String,
pub state: ScrollState,
}
impl<T: Clone> SelectionList<T> {
pub fn new(items: Vec<SelectionItem<T>>) -> Self {
let mut this = Self {
items,
query: String::new(),
state: ScrollState::new(),
};
let visible_len = this.visible_rows().len();
this.state.clamp_selection(visible_len);
this.state
.ensure_visible(visible_len, visible_len.min(MAX_POPUP_ROWS));
this
}
pub fn set_query(&mut self, query: &str) {
if self.query == query {
return;
}
self.query.clear();
self.query.push_str(query);
let visible_len = self.visible_rows().len();
if visible_len == 0 {
self.state.reset();
} else {
self.state.selected_idx = Some(0);
self.state
.ensure_visible(visible_len, visible_len.min(MAX_POPUP_ROWS));
}
}
pub fn move_up(&mut self) {
let len = self.visible_rows().len();
self.state.move_up_wrap(len);
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
pub fn move_down(&mut self) {
let len = self.visible_rows().len();
self.state.move_down_wrap(len);
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
}
pub fn selected_value(&self) -> Option<T> {
let rows = self.visible_rows();
self.state
.selected_idx
.and_then(|idx| rows.get(idx))
.and_then(|r| r.1)
.cloned()
}
/// Visible rows paired with a reference to the underlying value (if any).
/// The GenericDisplayRow contains only presentation data; we pair it with
/// an Option<&T> so callers can map the selection back to a value.
pub fn visible_rows(&self) -> Vec<(GenericDisplayRow, Option<&T>)> {
let query = self.query.trim();
let to_row = |it: &SelectionItem<T>, match_indices: Option<Vec<usize>>| GenericDisplayRow {
name: it.name.clone(),
match_indices,
is_current: it.is_current,
description: it.description.clone(),
};
if query.is_empty() {
return self
.items
.iter()
.map(|it| (to_row(it, None), Some(&it.value)))
.collect();
}
let mut out: Vec<(GenericDisplayRow, Option<&T>, i32, usize)> = Vec::new();
for it in self.items.iter() {
if let Some((indices, score)) = fuzzy_match(&it.name, query) {
out.push((
to_row(it, Some(indices)),
Some(&it.value),
score,
it.name.len(),
));
continue;
}
let mut best_alias_score: Option<i32> = None;
for alias in it.aliases.iter() {
if let Some((_idx, score)) = fuzzy_match(alias, query) {
best_alias_score = Some(best_alias_score.map_or(score, |s| s.max(score)));
}
}
if let Some(score) = best_alias_score {
out.push((to_row(it, None), Some(&it.value), score, it.name.len()));
}
}
out.sort_by(|a, b| {
a.2.cmp(&b.2)
.then_with(|| a.0.name.cmp(&b.0.name))
.then_with(|| a.3.cmp(&b.3))
});
out.into_iter()
.map(|(row, val, _score, _)| (row, val))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::SelectionItem;
use super::SelectionList;
#[test]
fn selection_list_query_and_navigation() {
let items = vec![
SelectionItem::new("a", "Auto".to_string()).with_aliases(vec!["auto".into()]),
SelectionItem::new("u", "Untrusted".to_string()).with_aliases(vec!["untrusted".into()]),
SelectionItem::new("r", "Read only".to_string()).with_aliases(vec!["read-only".into()]),
];
let mut list = SelectionList::new(items);
let rows = list.visible_rows();
assert_eq!(rows.len(), 3);
assert_eq!(list.selected_value(), Some("a"));
list.move_up();
assert_eq!(list.selected_value(), Some("r"));
list.move_down();
assert_eq!(list.selected_value(), Some("a"));
list.set_query("auto");
let rows = list.visible_rows();
assert_eq!(rows.len(), 1);
assert_eq!(list.selected_value(), Some("a"));
list.set_query("read-only");
let rows = list.visible_rows();
assert_eq!(rows.len(), 1);
assert_eq!(list.selected_value(), Some("r"));
list.set_query("not-a-match");
let rows = list.visible_rows();
assert_eq!(rows.len(), 0);
assert!(list.selected_value().is_none());
}
}

View File

@@ -0,0 +1,158 @@
//! Generic selection popup for multiple use-cases (models, execution modes, etc.).
//!
//! Construct with `new_model` or `new_execution_modes`, pass key events to move
//! selection and update the query via `set_query`, then render with
//! `selection_popup_common::render_rows`.
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use super::popup_consts::MAX_POPUP_ROWS;
use super::selection_list::SelectionItem;
use super::selection_list::SelectionList;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use crate::command_utils::ExecutionPreset;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SelectionKind {
Model,
Execution,
}
#[derive(Clone)]
pub(crate) enum SelectionValue {
Model(String),
Execution {
approval: AskForApproval,
sandbox: SandboxPolicy,
},
}
pub(crate) struct SelectionPopup {
kind: SelectionKind,
list: SelectionList<SelectionValue>,
}
impl SelectionPopup {
pub(crate) fn new_model(current_model: &str, options: Vec<String>) -> Self {
let mut items: Vec<SelectionItem<SelectionValue>> = Vec::new();
items.push(
SelectionItem::new(
SelectionValue::Model(current_model.to_string()),
current_model.to_string(),
)
.mark_current(true),
);
for m in options.into_iter().filter(|m| m != current_model) {
items.push(SelectionItem::new(SelectionValue::Model(m.clone()), m));
}
Self {
kind: SelectionKind::Model,
list: SelectionList::new(items),
}
}
pub(crate) fn new_execution_modes(
current_approval: AskForApproval,
current_sandbox: &SandboxPolicy,
) -> Self {
let presets: Vec<ExecutionPreset> = vec![
ExecutionPreset::ReadOnly,
ExecutionPreset::Untrusted,
ExecutionPreset::Auto,
ExecutionPreset::FullYolo,
];
let mut items: Vec<SelectionItem<SelectionValue>> = Vec::new();
for p in presets.into_iter() {
let (a, s) = p.to_policies();
let name = p.label().to_string();
let desc = Some(p.description().to_string());
let mut item = SelectionItem::new(
SelectionValue::Execution {
approval: a,
sandbox: s.clone(),
},
name,
)
.with_description(desc)
.with_aliases(match p {
ExecutionPreset::ReadOnly => vec!["read-only".to_string(), "readonly".to_string()],
ExecutionPreset::Untrusted => vec!["untrusted".to_string()],
ExecutionPreset::Auto => vec!["auto".to_string()],
ExecutionPreset::FullYolo => vec!["full-yolo".to_string(), "full yolo".to_string()],
});
if ExecutionPreset::from_policies(current_approval, current_sandbox) == Some(p) {
item = item.mark_current(true);
}
items.push(item);
}
Self {
kind: SelectionKind::Execution,
list: SelectionList::new(items),
}
}
pub(crate) fn kind(&self) -> SelectionKind {
self.kind
}
pub(crate) fn set_query(&mut self, query: &str) {
self.list.set_query(query);
}
pub(crate) fn move_up(&mut self) {
self.list.move_up();
}
pub(crate) fn move_down(&mut self) {
self.list.move_down();
}
pub(crate) fn calculate_required_height(&self) -> u16 {
self.list.visible_rows().len().clamp(1, MAX_POPUP_ROWS) as u16
}
pub(crate) fn selected_value(&self) -> Option<SelectionValue> {
self.list.selected_value()
}
fn visible_rows(&self) -> Vec<GenericDisplayRow> {
self.list
.visible_rows()
.into_iter()
.map(|(row, _)| row)
.collect()
}
}
impl WidgetRef for &SelectionPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let rows_all = self.visible_rows();
render_rows(area, buf, &rows_all, &self.list.state, MAX_POPUP_ROWS);
}
}
#[cfg(test)]
mod tests {
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
#[test]
fn execution_selector_includes_full_yolo() {
// Set a benign current mode; we only care about rows.
let popup = super::SelectionPopup::new_execution_modes(
AskForApproval::OnFailure,
&SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
include_default_writable_roots: true,
},
);
let rows = popup.visible_rows();
let labels: Vec<String> = rows.into_iter().map(|r| r.name).collect();
assert!(
labels.iter().any(|l| l.contains("Danger")),
"selector should include 'Danger'"
);
}
}

View File

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

View File

@@ -5,11 +5,14 @@ use std::sync::Arc;
use codex_core::codex_wrapper::CodexConversation;
use codex_core::codex_wrapper::init_codex;
use codex_core::config::Config;
use codex_core::config::ConfigToml;
use codex_core::openai_model_info::get_all_model_names;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
use codex_core::protocol::AgentReasoningEvent;
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::ErrorEvent;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
@@ -43,6 +46,7 @@ use crate::history_cell::CommandOutput;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use crate::user_approval_widget::ApprovalRequest;
use codex_core::protocol::SandboxPolicy;
use codex_file_search::FileMatch;
struct RunningCommand {
@@ -63,7 +67,10 @@ pub(crate) struct ChatWidget<'a> {
// We wait for the final AgentMessage event and then emit the full text
// at once into scrollback so the history contains a single message.
answer_buffer: String,
new_session: bool,
running_commands: HashMap<String, RunningCommand>,
cli_flags_used: Vec<String>,
cli_model: Option<String>,
}
struct UserMessage {
@@ -95,6 +102,8 @@ impl ChatWidget<'_> {
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
enhanced_keys_supported: bool,
cli_flags_used: Vec<String>,
cli_model: Option<String>,
) -> Self {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
@@ -150,7 +159,10 @@ impl ChatWidget<'_> {
token_usage: TokenUsage::default(),
reasoning_buffer: String::new(),
answer_buffer: String::new(),
new_session: true,
running_commands: HashMap::new(),
cli_flags_used,
cli_model,
}
}
@@ -223,8 +235,22 @@ impl ChatWidget<'_> {
EventMsg::SessionConfigured(event) => {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
// Record session information at the top of the conversation.
self.add_to_history(HistoryCell::new_session_info(&self.config, event, true));
if self.new_session {
let flags: Option<&[String]> = if self.cli_flags_used.is_empty() {
None
} else {
Some(&self.cli_flags_used)
};
self.add_to_history(HistoryCell::new_session_info(
&self.config,
event,
true,
flags,
));
self.new_session = false;
}
if let Some(user_message) = self.initial_user_message.take() {
// If the user provided an initial message, add it to the
@@ -510,6 +536,103 @@ impl ChatWidget<'_> {
.set_token_usage(self.token_usage.clone(), self.config.model_context_window);
}
/// Open the model selection view in the bottom pane.
pub(crate) fn show_model_selector(&mut self) {
use std::collections::HashSet;
let current = self.config.model.clone();
// Collect unique options from built-ins, current config, CLI, and config.toml.
let mut set: HashSet<String> = get_all_model_names()
.into_iter()
.map(|s| s.to_string())
.collect();
// Always include the currently configured model (covers custom values).
set.insert(current.clone());
// Include model specified via --model if present.
if let Some(m) = &self.cli_model {
set.insert(m.clone());
}
// Append any models found in config.toml profiles and top-level model.
let config_path = self.config.codex_home.join("config.toml");
if let Ok(contents) = std::fs::read_to_string(&config_path) {
if let Ok(cfg) = toml::from_str::<ConfigToml>(&contents) {
if let Some(m) = cfg.model {
set.insert(m);
}
for (_name, profile) in cfg.profiles.into_iter() {
if let Some(m) = profile.model {
set.insert(m);
}
}
}
}
// Present options in a stable order for the UI.
let mut options: Vec<String> = set.into_iter().collect();
options.sort();
self.bottom_pane.show_model_selector(&current, options);
}
/// Update the current model and reconfigure the running Codex session.
pub(crate) fn update_model_and_reconfigure(&mut self, model: String) {
// Update local config so UI reflects the new model.
let changed = self.config.model != model;
self.config.model = model.clone();
// Emit an event in the conversation log so the change is visible.
if changed {
self.add_to_history(HistoryCell::new_background_event(format!(
"Set model to {model}."
)));
}
// Reconfigure the agent session with the same provider and policies.
// Build the op from the config to avoid drift when fields are added.
let op = self
.config
.to_configure_session_op(None, self.config.user_instructions.clone());
self.submit_op(op);
self.request_redraw();
}
/// Open the execution-mode selection view in the bottom pane.
pub(crate) fn show_execution_selector(&mut self) {
let current_approval = self.config.approval_policy;
let current_sandbox = &self.config.sandbox_policy;
self.bottom_pane
.show_execution_selector(current_approval, current_sandbox);
}
/// Update both approval policy and sandbox, then reconfigure the running session.
pub(crate) fn update_execution_mode_and_reconfigure(
&mut self,
approval: AskForApproval,
sandbox: SandboxPolicy,
) {
let approval_changed = self.config.approval_policy != approval;
let sandbox_changed = self.config.sandbox_policy != sandbox;
self.config.approval_policy = approval;
self.config.sandbox_policy = sandbox.clone();
if approval_changed || sandbox_changed {
let label = crate::command_utils::execution_mode_label(approval, &sandbox);
self.add_to_history(HistoryCell::new_background_event(format!(
"Set execution mode to {label}."
)));
}
let op = self
.config
.to_configure_session_op(None, self.config.user_instructions.clone());
self.submit_op(op);
self.request_redraw();
}
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
self.bottom_pane.cursor_pos(area)
}

View File

@@ -0,0 +1,206 @@
//! Small shared helpers for slash-command argument parsing and execution-mode display.
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
/// Canonical execution presets that combine approval policy and sandbox policy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecutionPreset {
/// never prompt; read-only FS
ReadOnly,
/// ask to retry outside sandbox only on sandbox breach; read-only FS
Untrusted,
/// auto within workspace sandbox; ask to retry outside on breach
Auto,
/// DANGEROUS: disables sandbox and approvals entirely.
FullYolo,
}
impl ExecutionPreset {
pub fn label(self) -> &'static str {
match self {
ExecutionPreset::ReadOnly => "Read only",
ExecutionPreset::Untrusted => "Untrusted",
ExecutionPreset::Auto => "Auto",
ExecutionPreset::FullYolo => "Danger",
}
}
pub fn description(self) -> &'static str {
match self {
ExecutionPreset::ReadOnly => "read only filesystem, never prompt for approval",
ExecutionPreset::Untrusted => "user confirms writes and commands outside sandbox",
ExecutionPreset::Auto => {
"auto approve writes in the workspace; ask to run outside sandbox"
}
ExecutionPreset::FullYolo => {
"disables sandbox and approvals; the agent can run any commands"
}
}
}
/// Mapping from preset to policies.
pub fn to_policies(self) -> (AskForApproval, SandboxPolicy) {
match self {
ExecutionPreset::ReadOnly => (AskForApproval::Never, SandboxPolicy::ReadOnly),
ExecutionPreset::Untrusted => (AskForApproval::OnFailure, SandboxPolicy::ReadOnly),
ExecutionPreset::Auto => (
AskForApproval::OnFailure,
SandboxPolicy::WorkspaceWrite {
writable_roots: vec![],
network_access: false,
include_default_writable_roots: true,
},
),
ExecutionPreset::FullYolo => (AskForApproval::Never, SandboxPolicy::DangerFullAccess),
}
}
/// Mapping from policies to a known preset.
pub fn from_policies(
approval: AskForApproval,
sandbox: &SandboxPolicy,
) -> Option<ExecutionPreset> {
match (approval, sandbox) {
(AskForApproval::Never, SandboxPolicy::ReadOnly) => Some(ExecutionPreset::ReadOnly),
(AskForApproval::OnFailure, SandboxPolicy::ReadOnly) => {
Some(ExecutionPreset::Untrusted)
}
(AskForApproval::OnFailure, SandboxPolicy::WorkspaceWrite { .. }) => {
Some(ExecutionPreset::Auto)
}
(AskForApproval::Never, SandboxPolicy::DangerFullAccess)
| (AskForApproval::OnFailure, SandboxPolicy::DangerFullAccess) => {
Some(ExecutionPreset::FullYolo)
}
_ => None,
}
}
/// Parse one of the canonical tokens: read-only | untrusted | auto.
pub fn parse_token(s: &str) -> Option<ExecutionPreset> {
let t = s.trim().to_ascii_lowercase();
let t = t.replace(' ', "-");
match t.as_str() {
"read-only" => Some(ExecutionPreset::ReadOnly),
"untrusted" => Some(ExecutionPreset::Untrusted),
"auto" => Some(ExecutionPreset::Auto),
"full-yolo" => Some(ExecutionPreset::FullYolo),
_ => None,
}
}
}
/// Strip a single pair of surrounding quotes from the provided string if present.
/// Supports straight and common curly quotes: '…', "…", ‘…’, “…”.
pub fn strip_surrounding_quotes(s: &str) -> &str {
const QUOTE_PAIRS: &[(char, char)] = &[('\"', '\"'), ('\'', '\''), ('“', '”'), ('', '')];
let t = s.trim();
if t.len() < 2 {
return t;
}
for &(open, close) in QUOTE_PAIRS {
if t.starts_with(open) && t.ends_with(close) {
let start = open.len_utf8();
let end = t.len() - close.len_utf8();
return &t[start..end];
}
}
t
}
/// Normalize a free-form token: trim whitespace and remove a single pair of surrounding quotes.
pub fn normalize_token(s: &str) -> String {
strip_surrounding_quotes(s).trim().to_string()
}
/// Map an (approval, sandbox) pair to a concise preset label used in the UI.
pub fn execution_mode_label(approval: AskForApproval, sandbox: &SandboxPolicy) -> &'static str {
ExecutionPreset::from_policies(approval, sandbox)
.map(|p| p.label())
.unwrap_or("Custom")
}
/// Parse a free-form token to an execution preset (approval+sandbox).
pub fn parse_execution_mode_token(s: &str) -> Option<(AskForApproval, SandboxPolicy)> {
ExecutionPreset::parse_token(s).map(|p| p.to_policies())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_quotes_variants() {
assert_eq!(strip_surrounding_quotes("\"o3\""), "o3");
assert_eq!(strip_surrounding_quotes("'o3'"), "o3");
assert_eq!(strip_surrounding_quotes("“o3”"), "o3");
assert_eq!(strip_surrounding_quotes("o3"), "o3");
assert_eq!(strip_surrounding_quotes("o3"), "o3");
assert_eq!(strip_surrounding_quotes(" o3 "), "o3");
}
#[test]
fn parse_execution_mode_aliases() {
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
let parse = parse_execution_mode_token;
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))
);
assert_eq!(
parse("full-yolo"),
Some((AskForApproval::Never, SandboxPolicy::DangerFullAccess))
);
assert_eq!(
parse("Full Yolo"),
Some((AskForApproval::Never, SandboxPolicy::DangerFullAccess))
);
assert_eq!(parse("unknown"), None);
assert!(parse(" AUTO ").is_some());
}
#[test]
fn execution_preset_round_trip() {
let presets = [
ExecutionPreset::ReadOnly,
ExecutionPreset::Untrusted,
ExecutionPreset::Auto,
ExecutionPreset::FullYolo,
];
for p in presets {
let (a, s) = p.to_policies();
assert_eq!(ExecutionPreset::from_policies(a, &s), Some(p));
assert!(!p.label().is_empty());
assert!(!p.description().is_empty());
let token = match p {
ExecutionPreset::ReadOnly => "read-only",
ExecutionPreset::Untrusted => "untrusted",
ExecutionPreset::Auto => "auto",
ExecutionPreset::FullYolo => "full-yolo",
};
assert_eq!(ExecutionPreset::parse_token(token), Some(p));
}
}
#[test]
fn full_yolo_label_is_danger() {
assert_eq!(ExecutionPreset::FullYolo.label(), "Danger");
}
}

View File

@@ -0,0 +1,147 @@
//! Fullscreen warning displayed when the user selects the fullyunsafe
//! execution preset (Full yolo). This screen blocks input until the user
//! explicitly confirms or cancels the action.
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Alignment;
use ratatui::layout::Constraint;
use ratatui::layout::Direction;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::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;
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\
The agent can and will do stupid and destructive things as your user. Only proceed if you fully understand the risks.";
pub(crate) enum DangerWarningOutcome {
Continue,
Cancel,
None,
}
pub(crate) struct DangerWarningScreen;
impl DangerWarningScreen {
pub(crate) fn new() -> Self {
Self
}
pub(crate) fn handle_key_event(&self, key_event: KeyEvent) -> DangerWarningOutcome {
match key_event.code {
KeyCode::Char('y') | KeyCode::Char('Y') => DangerWarningOutcome::Continue,
KeyCode::Char('n') | KeyCode::Esc | KeyCode::Char('q') => DangerWarningOutcome::Cancel,
_ => DangerWarningOutcome::None,
}
}
}
impl WidgetRef for &DangerWarningScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
const MIN_WIDTH: u16 = 45;
const MIN_HEIGHT: u16 = 15;
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
let p = Paragraph::new(DANGER_TEXT)
.wrap(Wrap { trim: true })
.alignment(Alignment::Center)
.style(Style::default().fg(Color::Red));
p.render(area, buf);
return;
}
let popup_width = std::cmp::max(MIN_WIDTH, (area.width as f32 * 0.6) as u16);
let popup_height = std::cmp::max(MIN_HEIGHT, (area.height as f32 * 0.3) as u16);
let popup_x = area.x + (area.width.saturating_sub(popup_width)) / 2;
let popup_y = area.y + (area.height.saturating_sub(popup_height)) / 2;
let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Plain)
.title(Span::styled(
"Danger: Full system access",
Style::default().add_modifier(Modifier::BOLD).fg(Color::Red),
));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(3)])
.split(inner);
let text_block = Block::default().borders(Borders::ALL);
let text_inner = text_block.inner(chunks[0]);
text_block.render(chunks[0], buf);
let p = Paragraph::new(DANGER_TEXT)
.wrap(Wrap { trim: true })
.alignment(Alignment::Left)
.style(Style::default().fg(Color::Red));
p.render(text_inner, buf);
let action_block = Block::default().borders(Borders::ALL);
let action_inner = action_block.inner(chunks[1]);
action_block.render(chunks[1], buf);
let action_text = Paragraph::new("press 'y' to proceed, 'n' to cancel")
.alignment(Alignment::Center)
.style(Style::default().add_modifier(Modifier::BOLD));
action_text.render(action_inner, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
#[test]
fn keys_map_to_expected_outcomes() {
let screen = DangerWarningScreen::new();
// Continue confirmations
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)),
DangerWarningOutcome::Continue
));
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::SHIFT)),
DangerWarningOutcome::Continue
));
// Cancellations
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)),
DangerWarningOutcome::Cancel
));
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)),
DangerWarningOutcome::Cancel
));
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
DangerWarningOutcome::Cancel
));
// Irrelevant key is ignored
assert!(matches!(
screen.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
DangerWarningOutcome::None
));
}
}

View File

@@ -152,6 +152,7 @@ impl HistoryCell {
config: &Config,
event: SessionConfiguredEvent,
is_first_event: bool,
cli_flags: Option<&[String]>,
) -> Self {
let SessionConfiguredEvent {
model,
@@ -199,6 +200,12 @@ impl HistoryCell {
for (key, value) in entries {
lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()]));
}
if let Some(flags) = cli_flags {
if !flags.is_empty() {
let flags_str = flags.join(" ");
lines.push(Line::from(vec!["cli flags: ".bold(), flags_str.into()]));
}
}
lines.push(Line::from(""));
HistoryCell::WelcomeMessage {
view: TextBlock::new(lines),

View File

@@ -25,7 +25,9 @@ mod bottom_pane;
mod chatwidget;
mod citation_regex;
mod cli;
mod command_utils;
mod custom_terminal;
mod danger_warning_screen;
mod exec_command;
mod file_search;
mod get_git_diff;
@@ -225,7 +227,42 @@ fn run_ratatui_app(
terminal.clear()?;
let Cli { prompt, images, .. } = cli;
let mut app = App::new(config.clone(), prompt, show_git_warning, images);
// Build a list of CLI flags that were explicitly provided, to surface in the welcome message.
let mut cli_flags_used: Vec<String> = Vec::new();
if let Some(ap) = cli.approval_policy {
// kebab-case variants via clap ValueEnum Debug formatting is fine here.
cli_flags_used.push(format!(
"--ask-for-approval {}",
match ap {
codex_common::ApprovalModeCliArg::Untrusted => "untrusted",
codex_common::ApprovalModeCliArg::OnFailure => "on-failure",
codex_common::ApprovalModeCliArg::Never => "never",
}
));
}
if let Some(sm) = cli.sandbox_mode {
let mode = match sm {
codex_common::SandboxModeCliArg::ReadOnly => "read-only",
codex_common::SandboxModeCliArg::WorkspaceWrite => "workspace-write",
codex_common::SandboxModeCliArg::DangerFullAccess => "danger-full-access",
};
cli_flags_used.push(format!("--sandbox {mode}"));
}
if cli.full_auto {
cli_flags_used.push("--full-auto".to_string());
}
if cli.dangerously_bypass_approvals_and_sandbox {
cli_flags_used.push("--dangerously-bypass-approvals-and-sandbox".to_string());
}
let mut app = App::new(
config.clone(),
prompt,
show_git_warning,
images,
cli_flags_used,
cli.model.clone(),
);
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
{

View File

@@ -1,3 +1,4 @@
use std::str::FromStr;
use strum::IntoEnumIterator;
use strum_macros::AsRefStr;
use strum_macros::EnumIter;
@@ -15,6 +16,8 @@ pub enum SlashCommand {
New,
Compact,
Diff,
Model,
Approvals,
Quit,
#[cfg(debug_assertions)]
TestApproval,
@@ -27,6 +30,8 @@ impl SlashCommand {
SlashCommand::New => "Start a new chat.",
SlashCommand::Compact => "Compact the chat history.",
SlashCommand::Quit => "Exit the application.",
SlashCommand::Model => "Select the model to use.",
SlashCommand::Approvals => "Select the execution mode.",
SlashCommand::Diff => {
"Show git diff of the working directory (including untracked files)"
}
@@ -46,3 +51,71 @@ impl SlashCommand {
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
SlashCommand::iter().map(|c| (c.command(), c)).collect()
}
/// Parsed representation of a line that may start with a slash command.
pub enum ParsedSlash<'a> {
/// A recognized command along with the left-trimmed arguments.
Command { cmd: SlashCommand, args: &'a str },
/// A leading slash and a token were present, but the token is not a known command.
Incomplete { token: &'a str },
/// Line does not represent a slash command.
None,
}
/// Parse the first line of input and detect a leading slash command.
///
/// Returns:
/// - ParsedSlash::Command if the token matches a known command; `args` is the
/// remainder of the line after the token, with leading whitespace trimmed.
/// - ParsedSlash::Incomplete if there is a token after '/', but it does not
/// correspond to a known command.
/// - ParsedSlash::None if the line does not start with '/'.
pub fn parse_slash_line(line: &str) -> ParsedSlash<'_> {
let Some(stripped) = line.strip_prefix('/') else {
return ParsedSlash::None;
};
let token_with_ws = stripped.trim_start();
let token = token_with_ws.split_whitespace().next().unwrap_or("");
if token.is_empty() {
return ParsedSlash::Incomplete { token: "" };
}
match SlashCommand::from_str(token) {
Ok(cmd) => {
let rest = &token_with_ws[token.len()..];
let args = rest.trim_start();
ParsedSlash::Command { cmd, args }
}
Err(_) => ParsedSlash::Incomplete { token },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_known_command_and_args() {
let p = parse_slash_line("/model gpt-4o");
match p {
ParsedSlash::Command { cmd, args } => {
assert_eq!(cmd, SlashCommand::Model);
assert_eq!(args, "gpt-4o");
}
_ => panic!("expected Command"),
}
}
#[test]
fn incomplete_for_unknown_token() {
let p = parse_slash_line("/not-a-cmd something");
match p {
ParsedSlash::Incomplete { token } => assert_eq!(token, "not-a-cmd"),
_ => panic!("expected Incomplete"),
}
}
#[test]
fn none_for_non_command() {
matches!(parse_slash_line("hello"), ParsedSlash::None);
}
}