mirror of
https://github.com/openai/codex.git
synced 2026-02-03 15:33:41 +00:00
Compare commits
50 Commits
centralize
...
multiple-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813f27cf1e | ||
|
|
039bf0a13d | ||
|
|
86ee572910 | ||
|
|
f2ad7ec313 | ||
|
|
8e363f0dbc | ||
|
|
726cf674fd | ||
|
|
ba366f6d38 | ||
|
|
e112464f99 | ||
|
|
16e1a5b45b | ||
|
|
20ebd6b2ce | ||
|
|
07745aad34 | ||
|
|
586be04491 | ||
|
|
8a61d397d4 | ||
|
|
a983c1f6ad | ||
|
|
b23cddd1d9 | ||
|
|
aec9a8b9b4 | ||
|
|
1448a11697 | ||
|
|
f0d9f24fc0 | ||
|
|
60e9eb683c | ||
|
|
f7f43f1b5b | ||
|
|
9c0f8a50b6 | ||
|
|
53c19b4d07 | ||
|
|
9f45d477e5 | ||
|
|
1aad659eba | ||
|
|
5d4ade38a4 | ||
|
|
4f2f4dcf6f | ||
|
|
8dea0e4cd2 | ||
|
|
145688f019 | ||
|
|
1afa537148 | ||
|
|
507f79deac | ||
|
|
d207169ea6 | ||
|
|
4e2cf0bb7a | ||
|
|
56e95f7ec7 | ||
|
|
fbc1ee7d62 | ||
|
|
f8e5b02320 | ||
|
|
02d16813bf | ||
|
|
7cf524d8b9 | ||
|
|
40cf8a819c | ||
|
|
55659e351c | ||
|
|
2326f99e03 | ||
|
|
91aa683ae9 | ||
|
|
9dce0d7882 | ||
|
|
661a4ff3f9 | ||
|
|
da3f90fdad | ||
|
|
fcbe6495f1 | ||
|
|
34edf573d7 | ||
|
|
f78f8d8c7c | ||
|
|
1836614c06 | ||
|
|
9db5c7af9e | ||
|
|
b294004ea9 |
20
codex-rs/Cargo.lock
generated
20
codex-rs/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }
|
||||
|
||||
155
codex-rs/common/src/fuzzy_match.rs
Normal file
155
codex-rs/common/src/fuzzy_match.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -23,3 +23,5 @@ mod sandbox_summary;
|
||||
|
||||
#[cfg(feature = "sandbox_summary")]
|
||||
pub use sandbox_summary::summarize_sandbox_policy;
|
||||
|
||||
pub mod fuzzy_match;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 },
|
||||
/// Full‑screen warning when switching to the fully‑unsafe 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 full‑screen 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 bottom‑anchor 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}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 free‑form query (without leading /model) and ensure it filters.
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE));
|
||||
|
||||
// Press Enter to select the (filtered) current row.
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
// We should receive a SelectModel for the filtered option.
|
||||
let mut selected: Option<String> = None;
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SelectModel(m)) => {
|
||||
selected = Some(m);
|
||||
break;
|
||||
}
|
||||
Ok(_) => continue,
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
assert_eq!(selected.as_deref(), Some("gpt-4o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_pastes_submission() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
5
codex-rs/tui/src/bottom_pane/popup_consts.rs
Normal file
5
codex-rs/tui/src/bottom_pane/popup_consts.rs
Normal 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;
|
||||
115
codex-rs/tui/src/bottom_pane/scroll_state.rs
Normal file
115
codex-rs/tui/src/bottom_pane/scroll_state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
204
codex-rs/tui/src/bottom_pane/selection_list.rs
Normal file
204
codex-rs/tui/src/bottom_pane/selection_list.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
158
codex-rs/tui/src/bottom_pane/selection_popup.rs
Normal file
158
codex-rs/tui/src/bottom_pane/selection_popup.rs
Normal 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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
129
codex-rs/tui/src/bottom_pane/selection_popup_common.rs
Normal file
129
codex-rs/tui/src/bottom_pane/selection_popup_common.rs
Normal 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)
|
||||
@@ -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(¤t, 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)
|
||||
}
|
||||
|
||||
206
codex-rs/tui/src/command_utils.rs
Normal file
206
codex-rs/tui/src/command_utils.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
147
codex-rs/tui/src/danger_warning_screen.rs
Normal file
147
codex-rs/tui/src/danger_warning_screen.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! Full‑screen warning displayed when the user selects the fully‑unsafe
|
||||
//! 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user