mirror of
https://github.com/openai/codex.git
synced 2026-05-17 09:43:19 +00:00
Compare commits
50 Commits
dev/efraze
...
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 = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
"codex-common",
|
||||||
"ignore",
|
"ignore",
|
||||||
"nucleo-matcher",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -873,6 +873,7 @@ dependencies = [
|
|||||||
"supports-color",
|
"supports-color",
|
||||||
"textwrap 0.16.2",
|
"textwrap 0.16.2",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml 0.8.23",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-appender",
|
"tracing-appender",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -2812,16 +2813,6 @@ dependencies = [
|
|||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.4.6"
|
version = "0.4.6"
|
||||||
@@ -4814,6 +4805,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_spanned 0.6.9",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime 0.6.11",
|
"toml_datetime 0.6.11",
|
||||||
|
"toml_write",
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4826,6 +4818,12 @@ dependencies = [
|
|||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_writer"
|
name = "toml_writer"
|
||||||
version = "1.0.2"
|
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.
|
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
|
### `--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.
|
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"]
|
cli = ["clap", "serde", "toml"]
|
||||||
elapsed = []
|
elapsed = []
|
||||||
sandbox_summary = []
|
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")]
|
#[cfg(feature = "sandbox_summary")]
|
||||||
pub use sandbox_summary::summarize_sandbox_policy;
|
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 user_instructions = get_user_instructions(&config).await;
|
||||||
|
|
||||||
let configure_session = Op::ConfigureSession {
|
let configure_session =
|
||||||
provider: config.model_provider.clone(),
|
config.to_configure_session_op(Some(config.model.clone()), user_instructions);
|
||||||
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 config = Arc::new(config);
|
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(
|
let client = ModelClient::new(
|
||||||
config.clone(),
|
client_config,
|
||||||
auth.clone(),
|
auth.clone(),
|
||||||
provider.clone(),
|
provider.clone(),
|
||||||
model_reasoning_effort,
|
model_reasoning_effort,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use crate::model_provider_info::ModelProviderInfo;
|
|||||||
use crate::model_provider_info::built_in_model_providers;
|
use crate::model_provider_info::built_in_model_providers;
|
||||||
use crate::openai_model_info::get_model_info;
|
use crate::openai_model_info::get_model_info;
|
||||||
use crate::protocol::AskForApproval;
|
use crate::protocol::AskForApproval;
|
||||||
|
use crate::protocol::Op;
|
||||||
use crate::protocol::SandboxPolicy;
|
use crate::protocol::SandboxPolicy;
|
||||||
use dirs::home_dir;
|
use dirs::home_dir;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -185,6 +186,32 @@ impl Config {
|
|||||||
// Step 4: merge with the strongly-typed overrides.
|
// Step 4: merge with the strongly-typed overrides.
|
||||||
Self::load_from_base_config_with_overrides(cfg, overrides, codex_home)
|
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
|
/// 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::WireApi;
|
||||||
pub use model_provider_info::built_in_model_providers;
|
pub use model_provider_info::built_in_model_providers;
|
||||||
mod models;
|
mod models;
|
||||||
mod openai_model_info;
|
pub mod openai_model_info;
|
||||||
mod openai_tools;
|
mod openai_tools;
|
||||||
pub mod plan_tool;
|
pub mod plan_tool;
|
||||||
mod project_doc;
|
mod project_doc;
|
||||||
|
|||||||
@@ -69,3 +69,8 @@ pub(crate) fn get_model_info(name: &str) -> Option<ModelInfo> {
|
|||||||
_ => None,
|
_ => 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]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
codex-common = { path = "../common" }
|
||||||
ignore = "0.4.23"
|
ignore = "0.4.23"
|
||||||
nucleo-matcher = "0.3.1"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1.0.110"
|
serde_json = "1.0.110"
|
||||||
tokio = { version = "1", features = ["full"] }
|
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::WalkBuilder;
|
||||||
use ignore::overrides::OverrideBuilder;
|
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 serde::Serialize;
|
||||||
use std::cell::UnsafeCell;
|
use std::cell::UnsafeCell;
|
||||||
use std::cmp::Reverse;
|
|
||||||
use std::collections::BinaryHeap;
|
use std::collections::BinaryHeap;
|
||||||
use std::num::NonZero;
|
use std::num::NonZero;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -24,17 +19,13 @@ pub use cli::Cli;
|
|||||||
|
|
||||||
/// A single match result returned from the search.
|
/// 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).
|
/// * `path` – Path to the matched file (relative to the search directory).
|
||||||
/// * `indices` – Optional list of character indices that matched the query.
|
/// * `indices` – Optional list of character positions that matched the query.
|
||||||
/// These are only filled when the caller of [`run`] sets
|
/// These are unique and sorted so callers can use them directly for highlighting.
|
||||||
/// `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.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct FileMatch {
|
pub struct FileMatch {
|
||||||
pub score: u32,
|
pub score: i32,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub indices: Option<Vec<u32>>, // Sorted & deduplicated when present
|
pub indices: Option<Vec<u32>>, // Sorted & deduplicated when present
|
||||||
@@ -130,7 +121,6 @@ pub fn run(
|
|||||||
cancel_flag: Arc<AtomicBool>,
|
cancel_flag: Arc<AtomicBool>,
|
||||||
compute_indices: bool,
|
compute_indices: bool,
|
||||||
) -> anyhow::Result<FileSearchResults> {
|
) -> anyhow::Result<FileSearchResults> {
|
||||||
let pattern = create_pattern(pattern_text);
|
|
||||||
// Create one BestMatchesList per worker thread so that each worker can
|
// Create one BestMatchesList per worker thread so that each worker can
|
||||||
// operate independently. The results across threads will be merged when
|
// operate independently. The results across threads will be merged when
|
||||||
// the traversal is complete.
|
// the traversal is complete.
|
||||||
@@ -139,13 +129,7 @@ pub fn run(
|
|||||||
num_best_matches_lists,
|
num_best_matches_lists,
|
||||||
} = create_worker_count(threads);
|
} = create_worker_count(threads);
|
||||||
let best_matchers_per_worker: Vec<UnsafeCell<BestMatchesList>> = (0..num_best_matches_lists)
|
let best_matchers_per_worker: Vec<UnsafeCell<BestMatchesList>> = (0..num_best_matches_lists)
|
||||||
.map(|_| {
|
.map(|_| UnsafeCell::new(BestMatchesList::new(limit.get(), pattern_text.to_string())))
|
||||||
UnsafeCell::new(BestMatchesList::new(
|
|
||||||
limit.get(),
|
|
||||||
pattern.clone(),
|
|
||||||
Matcher::new(nucleo_matcher::Config::DEFAULT),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Use the same tree-walker library that ripgrep uses. We use it directly so
|
// 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.
|
// 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;
|
let mut total_match_count = 0;
|
||||||
for best_list_cell in best_matchers_per_worker.iter() {
|
for best_list_cell in best_matchers_per_worker.iter() {
|
||||||
let best_list = unsafe { &*best_list_cell.get() };
|
let best_list = unsafe { &*best_list_cell.get() };
|
||||||
total_match_count += best_list.num_matches;
|
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() {
|
if global_heap.len() < limit.get() {
|
||||||
global_heap.push(Reverse((score, line.clone())));
|
global_heap.push((score, line.clone()));
|
||||||
} else if let Some(min_element) = global_heap.peek() {
|
} else if let Some(&(worst_score, _)) = global_heap.peek() {
|
||||||
if score > min_element.0.0 {
|
if score < worst_score {
|
||||||
global_heap.pop();
|
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);
|
sort_matches(&mut raw_matches);
|
||||||
|
|
||||||
// Transform into `FileMatch`, optionally computing indices.
|
// 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
|
let matches: Vec<FileMatch> = raw_matches
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(score, path)| {
|
.map(|(score, path)| {
|
||||||
let indices = if compute_indices {
|
let indices = if compute_indices {
|
||||||
let mut buf = Vec::<char>::new();
|
common_fuzzy_indices(&path, pattern_text)
|
||||||
let haystack: Utf32Str<'_> = Utf32Str::new(&path, &mut buf);
|
.map(|v| v.into_iter().map(|i| i as u32).collect())
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -279,9 +249,9 @@ pub fn run(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort matches in-place by descending score, then ascending path.
|
/// Sort matches in-place by ascending score, then ascending path.
|
||||||
fn sort_matches(matches: &mut [(u32, String)]) {
|
fn sort_matches(matches: &mut [(i32, String)]) {
|
||||||
matches.sort_by(|a, b| match b.0.cmp(&a.0) {
|
matches.sort_by(|a, b| match a.0.cmp(&b.0) {
|
||||||
std::cmp::Ordering::Equal => a.1.cmp(&b.1),
|
std::cmp::Ordering::Equal => a.1.cmp(&b.1),
|
||||||
other => other,
|
other => other,
|
||||||
});
|
});
|
||||||
@@ -291,39 +261,31 @@ fn sort_matches(matches: &mut [(u32, String)]) {
|
|||||||
struct BestMatchesList {
|
struct BestMatchesList {
|
||||||
max_count: usize,
|
max_count: usize,
|
||||||
num_matches: usize,
|
num_matches: usize,
|
||||||
pattern: Pattern,
|
pattern: String,
|
||||||
matcher: Matcher,
|
binary_heap: BinaryHeap<(i32, String)>,
|
||||||
binary_heap: BinaryHeap<Reverse<(u32, String)>>,
|
|
||||||
|
|
||||||
/// Internal buffer for converting strings to UTF-32.
|
|
||||||
utf32buf: Vec<char>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BestMatchesList {
|
impl BestMatchesList {
|
||||||
fn new(max_count: usize, pattern: Pattern, matcher: Matcher) -> Self {
|
fn new(max_count: usize, pattern: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
max_count,
|
max_count,
|
||||||
num_matches: 0,
|
num_matches: 0,
|
||||||
pattern,
|
pattern,
|
||||||
matcher,
|
|
||||||
binary_heap: BinaryHeap::new(),
|
binary_heap: BinaryHeap::new(),
|
||||||
utf32buf: Vec::<char>::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn insert(&mut self, line: &str) {
|
fn insert(&mut self, line: &str) {
|
||||||
let haystack: Utf32Str<'_> = Utf32Str::new(line, &mut self.utf32buf);
|
if let Some((_indices, score)) = common_fuzzy_match(line, &self.pattern) {
|
||||||
if let Some(score) = self.pattern.score(haystack, &mut self.matcher) {
|
// Count all matches; non-matches return None above.
|
||||||
// In the tests below, we verify that score() returns None for a
|
|
||||||
// non-match, so we can categorically increment the count here.
|
|
||||||
self.num_matches += 1;
|
self.num_matches += 1;
|
||||||
|
|
||||||
if self.binary_heap.len() < self.max_count {
|
if self.binary_heap.len() < self.max_count {
|
||||||
self.binary_heap.push(Reverse((score, line.to_string())));
|
self.binary_heap.push((score, line.to_string()));
|
||||||
} else if let Some(min_element) = self.binary_heap.peek() {
|
} else if let Some(&(worst_score, _)) = self.binary_heap.peek() {
|
||||||
if score > min_element.0.0 {
|
if score < worst_score {
|
||||||
self.binary_heap.pop();
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn verify_score_is_none_for_non_match() {
|
fn verify_no_match_does_not_increment_or_push() {
|
||||||
let mut utf32buf = Vec::<char>::new();
|
let mut list = BestMatchesList::new(5, "zzz".to_string());
|
||||||
let line = "hello";
|
list.insert("hello");
|
||||||
let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT);
|
assert_eq!(list.num_matches, 0);
|
||||||
let haystack: Utf32Str<'_> = Utf32Str::new(line, &mut utf32buf);
|
assert_eq!(list.binary_heap.len(), 0);
|
||||||
let pattern = create_pattern("zzz");
|
|
||||||
let score = pattern.score(haystack, &mut matcher);
|
|
||||||
assert_eq!(score, None);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -388,11 +338,11 @@ mod tests {
|
|||||||
|
|
||||||
sort_matches(&mut matches);
|
sort_matches(&mut matches);
|
||||||
|
|
||||||
// Highest score first; ties broken alphabetically.
|
// Lowest score first; ties broken alphabetically.
|
||||||
let expected = vec![
|
let expected = vec![
|
||||||
|
(90, "zzz".to_string()),
|
||||||
(100, "a_path".to_string()),
|
(100, "a_path".to_string()),
|
||||||
(100, "b_path".to_string()),
|
(100, "b_path".to_string()),
|
||||||
(90, "zzz".to_string()),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
assert_eq!(matches, expected);
|
assert_eq!(matches, expected);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ tokio = { version = "1", features = [
|
|||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
"signal",
|
"signal",
|
||||||
] }
|
] }
|
||||||
|
toml = "0.8"
|
||||||
tracing = { version = "0.1.41", features = ["log"] }
|
tracing = { version = "0.1.41", features = ["log"] }
|
||||||
tracing-appender = "0.2.3"
|
tracing-appender = "0.2.3"
|
||||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::app_event::AppEvent;
|
use crate::app_event::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use crate::chatwidget::ChatWidget;
|
use crate::chatwidget::ChatWidget;
|
||||||
|
use crate::danger_warning_screen::DangerWarningOutcome;
|
||||||
|
use crate::danger_warning_screen::DangerWarningScreen;
|
||||||
use crate::file_search::FileSearchManager;
|
use crate::file_search::FileSearchManager;
|
||||||
use crate::get_git_diff::get_git_diff;
|
use crate::get_git_diff::get_git_diff;
|
||||||
use crate::git_warning_screen::GitWarningOutcome;
|
use crate::git_warning_screen::GitWarningOutcome;
|
||||||
@@ -16,9 +18,13 @@ use crossterm::SynchronizedUpdate;
|
|||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use crossterm::event::KeyEventKind;
|
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 crossterm::terminal::supports_keyboard_enhancement;
|
||||||
|
use ratatui::backend::Backend;
|
||||||
use ratatui::layout::Offset;
|
use ratatui::layout::Offset;
|
||||||
use ratatui::prelude::Backend;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::text::Line;
|
use ratatui::text::Line;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -43,8 +49,18 @@ enum AppState<'a> {
|
|||||||
},
|
},
|
||||||
/// The start-up warning that recommends running codex inside a Git repo.
|
/// The start-up warning that recommends running codex inside a Git repo.
|
||||||
GitWarning { screen: GitWarningScreen },
|
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> {
|
pub(crate) struct App<'a> {
|
||||||
app_event_tx: AppEventSender,
|
app_event_tx: AppEventSender,
|
||||||
app_event_rx: Receiver<AppEvent>,
|
app_event_rx: Receiver<AppEvent>,
|
||||||
@@ -65,6 +81,16 @@ pub(crate) struct App<'a> {
|
|||||||
chat_args: Option<ChatWidgetArgs>,
|
chat_args: Option<ChatWidgetArgs>,
|
||||||
|
|
||||||
enhanced_keys_supported: bool,
|
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
|
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
|
||||||
@@ -75,14 +101,54 @@ struct ChatWidgetArgs {
|
|||||||
initial_prompt: Option<String>,
|
initial_prompt: Option<String>,
|
||||||
initial_images: Vec<PathBuf>,
|
initial_images: Vec<PathBuf>,
|
||||||
enhanced_keys_supported: bool,
|
enhanced_keys_supported: bool,
|
||||||
|
cli_flags_used: Vec<String>,
|
||||||
|
cli_model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App<'_> {
|
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(
|
pub(crate) fn new(
|
||||||
config: Config,
|
config: Config,
|
||||||
initial_prompt: Option<String>,
|
initial_prompt: Option<String>,
|
||||||
show_git_warning: bool,
|
show_git_warning: bool,
|
||||||
initial_images: Vec<std::path::PathBuf>,
|
initial_images: Vec<std::path::PathBuf>,
|
||||||
|
cli_flags_used: Vec<String>,
|
||||||
|
cli_model: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (app_event_tx, app_event_rx) = channel();
|
let (app_event_tx, app_event_rx) = channel();
|
||||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||||
@@ -121,13 +187,9 @@ impl App<'_> {
|
|||||||
let pasted = pasted.replace("\r", "\n");
|
let pasted = pasted.replace("\r", "\n");
|
||||||
app_event_tx.send(AppEvent::Paste(pasted));
|
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_prompt,
|
||||||
initial_images,
|
initial_images,
|
||||||
enhanced_keys_supported,
|
enhanced_keys_supported,
|
||||||
|
cli_flags_used: cli_flags_used.clone(),
|
||||||
|
cli_model: cli_model.clone(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -152,6 +216,8 @@ impl App<'_> {
|
|||||||
initial_prompt,
|
initial_prompt,
|
||||||
initial_images,
|
initial_images,
|
||||||
enhanced_keys_supported,
|
enhanced_keys_supported,
|
||||||
|
cli_flags_used.clone(),
|
||||||
|
cli_model.clone(),
|
||||||
);
|
);
|
||||||
(
|
(
|
||||||
AppState::Chat {
|
AppState::Chat {
|
||||||
@@ -172,6 +238,9 @@ impl App<'_> {
|
|||||||
pending_redraw,
|
pending_redraw,
|
||||||
chat_args,
|
chat_args,
|
||||||
enhanced_keys_supported,
|
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 => {
|
AppEvent::Redraw => {
|
||||||
std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??;
|
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) => {
|
AppEvent::KeyEvent(key_event) => {
|
||||||
match key_event {
|
match key_event {
|
||||||
@@ -227,47 +328,38 @@ impl App<'_> {
|
|||||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||||
kind: KeyEventKind::Press,
|
kind: KeyEventKind::Press,
|
||||||
..
|
..
|
||||||
} => {
|
} => match &mut self.app_state {
|
||||||
match &mut self.app_state {
|
AppState::Chat { widget } => {
|
||||||
AppState::Chat { widget } => {
|
widget.on_ctrl_c();
|
||||||
widget.on_ctrl_c();
|
|
||||||
}
|
|
||||||
AppState::GitWarning { .. } => {
|
|
||||||
// No-op.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
AppState::GitWarning { .. } => {}
|
||||||
|
AppState::DangerWarning { .. } => {}
|
||||||
|
},
|
||||||
KeyEvent {
|
KeyEvent {
|
||||||
code: KeyCode::Char('d'),
|
code: KeyCode::Char('d'),
|
||||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||||
kind: KeyEventKind::Press,
|
kind: KeyEventKind::Press,
|
||||||
..
|
..
|
||||||
} => {
|
} => match &mut self.app_state {
|
||||||
match &mut self.app_state {
|
AppState::Chat { widget } => {
|
||||||
AppState::Chat { widget } => {
|
if widget.composer_is_empty() {
|
||||||
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 { .. } => {
|
|
||||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
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 {
|
KeyEvent {
|
||||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
self.dispatch_key_event(key_event);
|
self.dispatch_key_event(key_event);
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {}
|
||||||
// Ignore Release key events for now.
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
AppEvent::Paste(text) => {
|
AppEvent::Paste(text) => {
|
||||||
@@ -279,36 +371,81 @@ impl App<'_> {
|
|||||||
AppEvent::ExitRequest => {
|
AppEvent::ExitRequest => {
|
||||||
break;
|
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 {
|
AppEvent::CodexOp(op) => match &mut self.app_state {
|
||||||
AppState::Chat { widget } => widget.submit_op(op),
|
AppState::Chat { widget } => widget.submit_op(op),
|
||||||
AppState::GitWarning { .. } => {}
|
AppState::GitWarning { .. } => {}
|
||||||
|
AppState::DangerWarning { widget, .. } => widget.submit_op(op),
|
||||||
},
|
},
|
||||||
AppEvent::LatestLog(line) => match &mut self.app_state {
|
AppEvent::LatestLog(line) => match &mut self.app_state {
|
||||||
AppState::Chat { widget } => widget.update_latest_log(line),
|
AppState::Chat { widget } => widget.update_latest_log(line),
|
||||||
AppState::GitWarning { .. } => {}
|
AppState::GitWarning { .. } => {}
|
||||||
|
AppState::DangerWarning { widget, .. } => widget.update_latest_log(line),
|
||||||
},
|
},
|
||||||
AppEvent::DispatchCommand(command) => match command {
|
AppEvent::DispatchCommand { cmd, args } => match (cmd, args.as_deref()) {
|
||||||
SlashCommand::New => {
|
(SlashCommand::New, _) => {
|
||||||
let new_widget = Box::new(ChatWidget::new(
|
let new_widget = Box::new(ChatWidget::new(
|
||||||
self.config.clone(),
|
self.config.clone(),
|
||||||
self.app_event_tx.clone(),
|
self.app_event_tx.clone(),
|
||||||
None,
|
None,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
self.enhanced_keys_supported,
|
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_state = AppState::Chat { widget: new_widget };
|
||||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||||
}
|
}
|
||||||
SlashCommand::Compact => {
|
(SlashCommand::Compact, _) => {
|
||||||
if let AppState::Chat { widget } = &mut self.app_state {
|
if let AppState::Chat { widget } = &mut self.app_state {
|
||||||
widget.clear_token_usage();
|
widget.clear_token_usage();
|
||||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
self.app_event_tx.send(AppEvent::CodexOp(Op::Compact));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SlashCommand::Quit => {
|
(SlashCommand::Quit, _) => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
SlashCommand::Diff => {
|
(SlashCommand::Diff, _) => {
|
||||||
let (is_git_repo, diff_text) = match get_git_diff() {
|
let (is_git_repo, diff_text) = match get_git_diff() {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -330,7 +467,7 @@ impl App<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
SlashCommand::TestApproval => {
|
(SlashCommand::TestApproval, _) => {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||||
@@ -338,12 +475,6 @@ impl App<'_> {
|
|||||||
|
|
||||||
self.app_event_tx.send(AppEvent::CodexEvent(Event {
|
self.app_event_tx.send(AppEvent::CodexEvent(Event {
|
||||||
id: "1".to_string(),
|
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(
|
msg: EventMsg::ApplyPatchApprovalRequest(
|
||||||
ApplyPatchApprovalRequestEvent {
|
ApplyPatchApprovalRequestEvent {
|
||||||
call_id: "1".to_string(),
|
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) => {
|
AppEvent::StartFileSearch(query) => {
|
||||||
self.file_search.on_user_query(query);
|
self.file_search.on_user_query(query);
|
||||||
@@ -388,6 +528,7 @@ impl App<'_> {
|
|||||||
match &self.app_state {
|
match &self.app_state {
|
||||||
AppState::Chat { widget } => widget.token_usage().clone(),
|
AppState::Chat { widget } => widget.token_usage().clone(),
|
||||||
AppState::GitWarning { .. } => codex_core::protocol::TokenUsage::default(),
|
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 {
|
let desired_height = match &self.app_state {
|
||||||
AppState::Chat { widget } => widget.desired_height(size.width),
|
AppState::Chat { widget } => widget.desired_height(size.width),
|
||||||
AppState::GitWarning { .. } => 10,
|
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;
|
let mut area = terminal.viewport_area;
|
||||||
area.height = desired_height.min(size.height);
|
area.height = desired_height.min(size.height);
|
||||||
area.width = size.width;
|
area.width = size.width;
|
||||||
@@ -443,9 +600,17 @@ impl App<'_> {
|
|||||||
if let Some((x, y)) = widget.cursor_pos(frame.area()) {
|
if let Some((x, y)) = widget.cursor_pos(frame.area()) {
|
||||||
frame.set_cursor_position((x, y));
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -471,6 +636,8 @@ impl App<'_> {
|
|||||||
args.initial_prompt,
|
args.initial_prompt,
|
||||||
args.initial_images,
|
args.initial_images,
|
||||||
args.enhanced_keys_supported,
|
args.enhanced_keys_supported,
|
||||||
|
args.cli_flags_used,
|
||||||
|
args.cli_model,
|
||||||
));
|
));
|
||||||
self.app_state = AppState::Chat { widget };
|
self.app_state = AppState::Chat { widget };
|
||||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||||
@@ -478,9 +645,47 @@ impl App<'_> {
|
|||||||
GitWarningOutcome::Quit => {
|
GitWarningOutcome::Quit => {
|
||||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||||
}
|
}
|
||||||
GitWarningOutcome::None => {
|
GitWarningOutcome::None => {}
|
||||||
// do nothing
|
},
|
||||||
|
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 {
|
match &mut self.app_state {
|
||||||
AppState::Chat { widget } => widget.handle_paste(pasted),
|
AppState::Chat { widget } => widget.handle_paste(pasted),
|
||||||
AppState::GitWarning { .. } => {}
|
AppState::GitWarning { .. } => {}
|
||||||
|
AppState::DangerWarning { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,6 +702,48 @@ impl App<'_> {
|
|||||||
match &mut self.app_state {
|
match &mut self.app_state {
|
||||||
AppState::Chat { widget } => widget.handle_codex_event(event),
|
AppState::Chat { widget } => widget.handle_codex_event(event),
|
||||||
AppState::GitWarning { .. } => {}
|
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 ratatui::text::Line;
|
||||||
|
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
|
use codex_core::protocol::AskForApproval;
|
||||||
|
use codex_core::protocol::SandboxPolicy;
|
||||||
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
pub(crate) enum AppEvent {
|
pub(crate) enum AppEvent {
|
||||||
@@ -31,8 +33,12 @@ pub(crate) enum AppEvent {
|
|||||||
LatestLog(String),
|
LatestLog(String),
|
||||||
|
|
||||||
/// Dispatch a recognized slash command from the UI (composer) to the app
|
/// Dispatch a recognized slash command from the UI (composer) to the app
|
||||||
/// layer so it can be handled centrally.
|
/// layer so it can be handled centrally. Optional `args` contains the
|
||||||
DispatchCommand(SlashCommand),
|
/// 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
|
/// Kick off an asynchronous file search for the given query (text after
|
||||||
/// the `@`). Previous searches may be cancelled by the app layer so there
|
/// the `@`). Previous searches may be cancelled by the app layer so there
|
||||||
@@ -48,4 +54,19 @@ pub(crate) enum AppEvent {
|
|||||||
},
|
},
|
||||||
|
|
||||||
InsertHistory(Vec<Line<'static>>),
|
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::chat_composer_history::ChatComposerHistory;
|
||||||
use super::command_popup::CommandPopup;
|
use super::command_popup::CommandPopup;
|
||||||
use super::file_search_popup::FileSearchPopup;
|
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::AppEvent;
|
||||||
use crate::app_event_sender::AppEventSender;
|
use crate::app_event_sender::AppEventSender;
|
||||||
use crate::bottom_pane::textarea::TextArea;
|
use crate::bottom_pane::textarea::TextArea;
|
||||||
use crate::bottom_pane::textarea::TextAreaState;
|
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 codex_file_search::FileMatch;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
|
||||||
@@ -54,7 +61,7 @@ pub(crate) struct ChatComposer {
|
|||||||
history: ChatComposerHistory,
|
history: ChatComposerHistory,
|
||||||
ctrl_c_quit_hint: bool,
|
ctrl_c_quit_hint: bool,
|
||||||
use_shift_enter_hint: bool,
|
use_shift_enter_hint: bool,
|
||||||
dismissed_file_popup_token: Option<String>,
|
dismissed: Dismissed,
|
||||||
current_file_query: Option<String>,
|
current_file_query: Option<String>,
|
||||||
pending_pastes: Vec<(String, String)>,
|
pending_pastes: Vec<(String, String)>,
|
||||||
token_usage_info: Option<TokenUsageInfo>,
|
token_usage_info: Option<TokenUsageInfo>,
|
||||||
@@ -66,9 +73,31 @@ enum ActivePopup {
|
|||||||
None,
|
None,
|
||||||
Command(CommandPopup),
|
Command(CommandPopup),
|
||||||
File(FileSearchPopup),
|
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 {
|
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(
|
pub fn new(
|
||||||
has_input_focus: bool,
|
has_input_focus: bool,
|
||||||
app_event_tx: AppEventSender,
|
app_event_tx: AppEventSender,
|
||||||
@@ -84,7 +113,10 @@ impl ChatComposer {
|
|||||||
history: ChatComposerHistory::new(),
|
history: ChatComposerHistory::new(),
|
||||||
ctrl_c_quit_hint: false,
|
ctrl_c_quit_hint: false,
|
||||||
use_shift_enter_hint,
|
use_shift_enter_hint,
|
||||||
dismissed_file_popup_token: None,
|
dismissed: Dismissed {
|
||||||
|
slash: None,
|
||||||
|
file: None,
|
||||||
|
},
|
||||||
current_file_query: None,
|
current_file_query: None,
|
||||||
pending_pastes: Vec::new(),
|
pending_pastes: Vec::new(),
|
||||||
token_usage_info: None,
|
token_usage_info: None,
|
||||||
@@ -98,6 +130,7 @@ impl ChatComposer {
|
|||||||
ActivePopup::None => 1u16,
|
ActivePopup::None => 1u16,
|
||||||
ActivePopup::Command(c) => c.calculate_required_height(),
|
ActivePopup::Command(c) => c.calculate_required_height(),
|
||||||
ActivePopup::File(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 {
|
let popup_height = match &self.active_popup {
|
||||||
ActivePopup::Command(popup) => popup.calculate_required_height(),
|
ActivePopup::Command(popup) => popup.calculate_required_height(),
|
||||||
ActivePopup::File(popup) => popup.calculate_required_height(),
|
ActivePopup::File(popup) => popup.calculate_required_height(),
|
||||||
|
ActivePopup::Selection(popup) => popup.calculate_required_height(),
|
||||||
ActivePopup::None => 1,
|
ActivePopup::None => 1,
|
||||||
};
|
};
|
||||||
let [textarea_rect, _] =
|
let [textarea_rect, _] =
|
||||||
@@ -167,8 +201,7 @@ impl ChatComposer {
|
|||||||
} else {
|
} else {
|
||||||
self.textarea.insert_str(&pasted);
|
self.textarea.insert_str(&pasted);
|
||||||
}
|
}
|
||||||
self.sync_command_popup();
|
self.sync_popups();
|
||||||
self.sync_file_search_popup();
|
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,20 +227,79 @@ impl ChatComposer {
|
|||||||
self.set_has_focus(has_focus);
|
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.
|
/// Handle a key event coming from the main UI.
|
||||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||||
let result = match &mut self.active_popup {
|
let result = match &mut self.active_popup {
|
||||||
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
||||||
ActivePopup::File(_) => self.handle_key_event_with_file_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),
|
ActivePopup::None => self.handle_key_event_without_popup(key_event),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update (or hide/show) popup after processing the key.
|
// Update (or hide/show) popup after processing the key.
|
||||||
self.sync_command_popup();
|
match &self.active_popup {
|
||||||
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
ActivePopup::Selection(_) => {
|
||||||
self.dismissed_file_popup_token = None;
|
self.sync_selection_popup();
|
||||||
} else {
|
}
|
||||||
self.sync_file_search_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
|
result
|
||||||
@@ -215,6 +307,7 @@ impl ChatComposer {
|
|||||||
|
|
||||||
/// Handle key event when the slash-command popup is visible.
|
/// Handle key event when the slash-command popup is visible.
|
||||||
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
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 {
|
let ActivePopup::Command(popup) = &mut self.active_popup else {
|
||||||
unreachable!();
|
unreachable!();
|
||||||
};
|
};
|
||||||
@@ -233,12 +326,26 @@ impl ChatComposer {
|
|||||||
popup.move_down();
|
popup.move_down();
|
||||||
(InputResult::None, true)
|
(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 {
|
KeyEvent {
|
||||||
code: KeyCode::Tab, ..
|
code: KeyCode::Tab, ..
|
||||||
} => {
|
} => {
|
||||||
if let Some(cmd) = popup.selected_command() {
|
if let Some(cmd) = popup.selected_command() {
|
||||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||||
|
|
||||||
let starts_with_cmd = first_line
|
let starts_with_cmd = first_line
|
||||||
.trim_start()
|
.trim_start()
|
||||||
.starts_with(&format!("/{}", cmd.command()));
|
.starts_with(&format!("/{}", cmd.command()));
|
||||||
@@ -255,18 +362,44 @@ impl ChatComposer {
|
|||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
if let Some(cmd) = popup.selected_command() {
|
if let Some(cmd) = popup.selected_command() {
|
||||||
// Send command to the app layer.
|
let args_opt = match parse_slash_line(&first_line_owned) {
|
||||||
self.app_event_tx.send(AppEvent::DispatchCommand(*cmd));
|
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("");
|
self.textarea.set_text("");
|
||||||
|
|
||||||
// Hide popup since the command has been dispatched.
|
|
||||||
self.active_popup = ActivePopup::None;
|
self.active_popup = ActivePopup::None;
|
||||||
return (InputResult::None, true);
|
return (InputResult::None, true);
|
||||||
}
|
}
|
||||||
// Fallback to default newline handling if no command selected.
|
let invalid_token = match parse_slash_line(&first_line_owned) {
|
||||||
self.handle_key_event_without_popup(key_event)
|
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),
|
input => self.handle_input_basic(input),
|
||||||
}
|
}
|
||||||
@@ -274,6 +407,7 @@ impl ChatComposer {
|
|||||||
|
|
||||||
/// Handle key events when file search popup is visible.
|
/// Handle key events when file search popup is visible.
|
||||||
fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
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 {
|
let ActivePopup::File(popup) = &mut self.active_popup else {
|
||||||
unreachable!();
|
unreachable!();
|
||||||
};
|
};
|
||||||
@@ -295,9 +429,8 @@ impl ChatComposer {
|
|||||||
KeyEvent {
|
KeyEvent {
|
||||||
code: KeyCode::Esc, ..
|
code: KeyCode::Esc, ..
|
||||||
} => {
|
} => {
|
||||||
// Hide popup without modifying text, remember token to avoid immediate reopen.
|
|
||||||
if let Some(tok) = Self::current_at_token(&self.textarea) {
|
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;
|
self.active_popup = ActivePopup::None;
|
||||||
(InputResult::None, true)
|
(InputResult::None, true)
|
||||||
@@ -312,7 +445,6 @@ impl ChatComposer {
|
|||||||
} => {
|
} => {
|
||||||
if let Some(sel) = popup.selected_match() {
|
if let Some(sel) = popup.selected_match() {
|
||||||
let sel_path = sel.to_string();
|
let sel_path = sel.to_string();
|
||||||
// Drop popup borrow before using self mutably again.
|
|
||||||
self.insert_selected_path(&sel_path);
|
self.insert_selected_path(&sel_path);
|
||||||
self.active_popup = ActivePopup::None;
|
self.active_popup = ActivePopup::None;
|
||||||
return (InputResult::None, true);
|
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.
|
/// Extract the `@token` that the cursor is currently positioned on, if any.
|
||||||
///
|
///
|
||||||
/// The returned string **does not** include the leading `@`.
|
/// The returned string **does not** include the leading `@`.
|
||||||
@@ -444,7 +667,6 @@ impl ChatComposer {
|
|||||||
.unwrap_or(after_cursor.len());
|
.unwrap_or(after_cursor.len());
|
||||||
let end_idx = cursor_offset + end_rel_idx;
|
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 =
|
let mut new_text =
|
||||||
String::with_capacity(text.len() - (end_idx - start_idx) + path.len() + 1);
|
String::with_capacity(text.len() - (end_idx - start_idx) + path.len() + 1);
|
||||||
new_text.push_str(&text[..start_idx]);
|
new_text.push_str(&text[..start_idx]);
|
||||||
@@ -458,11 +680,6 @@ impl ChatComposer {
|
|||||||
/// Handle key event when no popup is visible.
|
/// Handle key event when no popup is visible.
|
||||||
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||||
match key_event {
|
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 {
|
KeyEvent {
|
||||||
code: KeyCode::Up | KeyCode::Down,
|
code: KeyCode::Up | KeyCode::Down,
|
||||||
..
|
..
|
||||||
@@ -492,7 +709,6 @@ impl ChatComposer {
|
|||||||
let mut text = self.textarea.text().to_string();
|
let mut text = self.textarea.text().to_string();
|
||||||
self.textarea.set_text("");
|
self.textarea.set_text("");
|
||||||
|
|
||||||
// Replace all pending pastes in the text
|
|
||||||
for (placeholder, actual) in &self.pending_pastes {
|
for (placeholder, actual) in &self.pending_pastes {
|
||||||
if text.contains(placeholder) {
|
if text.contains(placeholder) {
|
||||||
text = text.replace(placeholder, actual);
|
text = text.replace(placeholder, actual);
|
||||||
@@ -513,7 +729,6 @@ impl ChatComposer {
|
|||||||
|
|
||||||
/// Handle generic Input events that modify the textarea content.
|
/// Handle generic Input events that modify the textarea content.
|
||||||
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
|
||||||
// Special handling for backspace on placeholders
|
|
||||||
if let KeyEvent {
|
if let KeyEvent {
|
||||||
code: KeyCode::Backspace,
|
code: KeyCode::Backspace,
|
||||||
..
|
..
|
||||||
@@ -523,12 +738,8 @@ impl ChatComposer {
|
|||||||
return (InputResult::None, true);
|
return (InputResult::None, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal input handling
|
|
||||||
self.textarea.input(input);
|
self.textarea.input(input);
|
||||||
let text_after = self.textarea.text();
|
let text_after = self.textarea.text();
|
||||||
|
|
||||||
// Check if any placeholders were removed and remove their corresponding pending pastes
|
|
||||||
self.pending_pastes
|
self.pending_pastes
|
||||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
.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) {
|
fn sync_command_popup(&mut self) {
|
||||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||||
let input_starts_with_slash = first_line.starts_with('/');
|
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 {
|
match &mut self.active_popup {
|
||||||
ActivePopup::Command(popup) => {
|
ActivePopup::Command(popup) => {
|
||||||
if input_starts_with_slash {
|
if input_starts_with_slash {
|
||||||
popup.on_composer_text_change(first_line.to_string());
|
popup.on_composer_text_change(first_line.to_string());
|
||||||
} else {
|
} else {
|
||||||
self.active_popup = ActivePopup::None;
|
self.active_popup = ActivePopup::None;
|
||||||
|
self.dismissed.slash = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
if input_starts_with_slash {
|
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();
|
let mut command_popup = CommandPopup::new();
|
||||||
command_popup.on_composer_text_change(first_line.to_string());
|
command_popup.on_composer_text_change(first_line.to_string());
|
||||||
self.active_popup = ActivePopup::Command(command_popup);
|
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) {
|
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) {
|
let query = match Self::current_at_token(&self.textarea) {
|
||||||
Some(token) => token,
|
Some(token) => token,
|
||||||
None => {
|
None => {
|
||||||
self.active_popup = ActivePopup::None;
|
self.active_popup = ActivePopup::None;
|
||||||
self.dismissed_file_popup_token = None;
|
self.dismissed.file = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
if self.dismissed.file.as_ref() == Some(&query) {
|
||||||
// If user dismissed popup for this exact query, don't reopen until text changes.
|
|
||||||
if self.dismissed_file_popup_token.as_ref() == Some(&query) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,7 +841,26 @@ impl ChatComposer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.current_file_query = Some(query);
|
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) {
|
fn set_has_focus(&mut self, has_focus: bool) {
|
||||||
@@ -633,6 +873,7 @@ impl WidgetRef for &ChatComposer {
|
|||||||
let popup_height = match &self.active_popup {
|
let popup_height = match &self.active_popup {
|
||||||
ActivePopup::Command(popup) => popup.calculate_required_height(),
|
ActivePopup::Command(popup) => popup.calculate_required_height(),
|
||||||
ActivePopup::File(popup) => popup.calculate_required_height(),
|
ActivePopup::File(popup) => popup.calculate_required_height(),
|
||||||
|
ActivePopup::Selection(popup) => popup.calculate_required_height(),
|
||||||
ActivePopup::None => 1,
|
ActivePopup::None => 1,
|
||||||
};
|
};
|
||||||
let [textarea_rect, popup_rect] =
|
let [textarea_rect, popup_rect] =
|
||||||
@@ -644,6 +885,9 @@ impl WidgetRef for &ChatComposer {
|
|||||||
ActivePopup::File(popup) => {
|
ActivePopup::File(popup) => {
|
||||||
popup.render_ref(popup_rect, buf);
|
popup.render_ref(popup_rect, buf);
|
||||||
}
|
}
|
||||||
|
ActivePopup::Selection(popup) => {
|
||||||
|
popup.render_ref(popup_rect, buf);
|
||||||
|
}
|
||||||
ActivePopup::None => {
|
ActivePopup::None => {
|
||||||
let bottom_line_rect = popup_rect;
|
let bottom_line_rect = popup_rect;
|
||||||
let key_hint_style = Style::default().fg(Color::Cyan);
|
let key_hint_style = Style::default().fg(Color::Cyan);
|
||||||
@@ -729,6 +973,7 @@ impl WidgetRef for &ChatComposer {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|
||||||
use crate::bottom_pane::AppEventSender;
|
use crate::bottom_pane::AppEventSender;
|
||||||
use crate::bottom_pane::ChatComposer;
|
use crate::bottom_pane::ChatComposer;
|
||||||
use crate::bottom_pane::InputResult;
|
use crate::bottom_pane::InputResult;
|
||||||
@@ -962,7 +1207,12 @@ mod tests {
|
|||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
|
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
|
||||||
Ok(t) => t,
|
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![
|
let test_cases = vec![
|
||||||
@@ -996,14 +1246,248 @@ mod tests {
|
|||||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal
|
let draw_res = terminal.draw(|f| f.render_widget_ref(&composer, f.area()));
|
||||||
.draw(|f| f.render_widget_ref(&composer, f.area()))
|
assert!(draw_res.is_ok(), "Failed to draw {name} composer");
|
||||||
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
|
|
||||||
|
|
||||||
assert_snapshot!(name, terminal.backend());
|
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]
|
#[test]
|
||||||
fn test_multiple_pastes_submission() {
|
fn test_multiple_pastes_submission() {
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
@@ -1014,18 +1498,15 @@ mod tests {
|
|||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer = ChatComposer::new(true, sender, false);
|
let mut composer = ChatComposer::new(true, sender, false);
|
||||||
|
|
||||||
// Define test cases: (paste content, is_large)
|
|
||||||
let test_cases = [
|
let test_cases = [
|
||||||
("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
|
("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
|
||||||
(" and ".to_string(), false),
|
(" and ".to_string(), false),
|
||||||
("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
|
("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Expected states after each paste
|
|
||||||
let mut expected_text = String::new();
|
let mut expected_text = String::new();
|
||||||
let mut expected_pending_count = 0;
|
let mut expected_pending_count = 0;
|
||||||
|
|
||||||
// Apply all pastes and build expected state
|
|
||||||
let states: Vec<_> = test_cases
|
let states: Vec<_> = test_cases
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(content, is_large)| {
|
.map(|(content, is_large)| {
|
||||||
@@ -1041,7 +1522,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Verify all intermediate states were correct
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
states,
|
states,
|
||||||
vec![
|
vec![
|
||||||
@@ -1067,7 +1547,6 @@ mod tests {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Submit and verify final expansion
|
|
||||||
let (result, _) =
|
let (result, _) =
|
||||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||||
if let InputResult::Submitted(text) = result {
|
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]
|
#[test]
|
||||||
fn test_placeholder_deletion() {
|
fn test_placeholder_deletion() {
|
||||||
use crossterm::event::KeyCode;
|
use crossterm::event::KeyCode;
|
||||||
@@ -1087,14 +1614,12 @@ mod tests {
|
|||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer = ChatComposer::new(true, sender, false);
|
let mut composer = ChatComposer::new(true, sender, false);
|
||||||
|
|
||||||
// Define test cases: (content, is_large)
|
|
||||||
let test_cases = [
|
let test_cases = [
|
||||||
("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
|
("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
|
||||||
(" and ".to_string(), false),
|
(" and ".to_string(), false),
|
||||||
("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
|
("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Apply all pastes
|
|
||||||
let mut current_pos = 0;
|
let mut current_pos = 0;
|
||||||
let states: Vec<_> = test_cases
|
let states: Vec<_> = test_cases
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1114,7 +1639,6 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Delete placeholders one by one and collect states
|
|
||||||
let mut deletion_states = vec![];
|
let mut deletion_states = vec![];
|
||||||
|
|
||||||
// First deletion
|
// First deletion
|
||||||
@@ -1133,7 +1657,6 @@ mod tests {
|
|||||||
composer.pending_pastes.len(),
|
composer.pending_pastes.len(),
|
||||||
));
|
));
|
||||||
|
|
||||||
// Verify all states
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
deletion_states,
|
deletion_states,
|
||||||
vec![
|
vec![
|
||||||
@@ -1153,7 +1676,6 @@ mod tests {
|
|||||||
let sender = AppEventSender::new(tx);
|
let sender = AppEventSender::new(tx);
|
||||||
let mut composer = ChatComposer::new(true, sender, false);
|
let mut composer = ChatComposer::new(true, sender, false);
|
||||||
|
|
||||||
// Define test cases: (cursor_position_from_end, expected_pending_count)
|
|
||||||
let test_cases = [
|
let test_cases = [
|
||||||
5, // Delete from middle - should clear tracking
|
5, // Delete from middle - should clear tracking
|
||||||
0, // Delete from end - 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::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
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 ratatui::widgets::WidgetRef;
|
||||||
|
|
||||||
use crate::slash_command::SlashCommand;
|
use crate::slash_command::SlashCommand;
|
||||||
use crate::slash_command::built_in_slash_commands;
|
use crate::slash_command::built_in_slash_commands;
|
||||||
|
|
||||||
const MAX_POPUP_ROWS: usize = 5;
|
use super::popup_consts::MAX_POPUP_ROWS;
|
||||||
/// Ideally this is enough to show the longest command name.
|
use super::selection_popup_common::GenericDisplayRow;
|
||||||
const FIRST_COLUMN_WIDTH: u16 = 20;
|
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 {
|
pub(crate) struct CommandPopup {
|
||||||
command_filter: String,
|
command_filter: String,
|
||||||
all_commands: Vec<(&'static str, SlashCommand)>,
|
all_commands: Vec<(&'static str, SlashCommand)>,
|
||||||
selected_idx: Option<usize>,
|
state: ScrollState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandPopup {
|
impl CommandPopup {
|
||||||
@@ -32,7 +23,7 @@ impl CommandPopup {
|
|||||||
Self {
|
Self {
|
||||||
command_filter: String::new(),
|
command_filter: String::new(),
|
||||||
all_commands: built_in_slash_commands(),
|
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.
|
// Reset or clamp selected index based on new filtered list.
|
||||||
let matches_len = self.filtered_commands().len();
|
let matches_len = self.filtered_commands().len();
|
||||||
self.selected_idx = match matches_len {
|
self.state.clamp_selection(matches_len);
|
||||||
0 => None,
|
self.state
|
||||||
_ => Some(self.selected_idx.unwrap_or(0).min(matches_len - 1)),
|
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine the preferred height of the popup. This is the number of
|
/// Determine the preferred height of the popup. This is the number of
|
||||||
/// rows required to show **at most** `MAX_POPUP_ROWS` commands plus the
|
/// rows required to show at most MAX_POPUP_ROWS commands.
|
||||||
/// table/border overhead (one line at the top and one at the bottom).
|
|
||||||
pub(crate) fn calculate_required_height(&self) -> u16 {
|
pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||||
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
|
self.filtered_commands().len().clamp(1, MAX_POPUP_ROWS) as u16
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the list of commands that match the current filter. Matching is
|
/// Compute fuzzy-filtered matches paired with optional highlight indices and score.
|
||||||
/// performed using a *prefix* comparison on the command name.
|
/// Sorted by ascending score, then by command name for stability.
|
||||||
fn filtered_commands(&self) -> Vec<&SlashCommand> {
|
fn filtered(&self) -> Vec<(&SlashCommand, Option<Vec<usize>>, i32)> {
|
||||||
self.all_commands
|
let filter = self.command_filter.trim();
|
||||||
.iter()
|
let mut out: Vec<(&SlashCommand, Option<Vec<usize>>, i32)> = Vec::new();
|
||||||
.filter_map(|(_name, cmd)| {
|
if filter.is_empty() {
|
||||||
if self.command_filter.is_empty()
|
for (_, cmd) in self.all_commands.iter() {
|
||||||
|| cmd
|
out.push((cmd, None, 0));
|
||||||
.command()
|
}
|
||||||
.starts_with(&self.command_filter.to_ascii_lowercase())
|
} else {
|
||||||
{
|
for (_, cmd) in self.all_commands.iter() {
|
||||||
Some(cmd)
|
if let Some((indices, score)) = fuzzy_match(cmd.command(), filter) {
|
||||||
} else {
|
out.push((cmd, Some(indices), score));
|
||||||
None
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
.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.
|
/// Move the selection cursor one step up.
|
||||||
pub(crate) fn move_up(&mut self) {
|
pub(crate) fn move_up(&mut self) {
|
||||||
if let Some(len) = self.filtered_commands().len().checked_sub(1) {
|
let matches = self.filtered_commands();
|
||||||
if len == usize::MAX {
|
let len = matches.len();
|
||||||
return;
|
self.state.move_up_wrap(len);
|
||||||
}
|
self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move the selection cursor one step down.
|
/// Move the selection cursor one step down.
|
||||||
pub(crate) fn move_down(&mut self) {
|
pub(crate) fn move_down(&mut self) {
|
||||||
let matches_len = self.filtered_commands().len();
|
let matches = self.filtered_commands();
|
||||||
if matches_len == 0 {
|
let matches_len = matches.len();
|
||||||
self.selected_idx = None;
|
self.state.move_down_wrap(matches_len);
|
||||||
return;
|
self.state
|
||||||
}
|
.ensure_visible(matches_len, MAX_POPUP_ROWS.min(matches_len));
|
||||||
|
|
||||||
match self.selected_idx {
|
|
||||||
Some(idx) if idx + 1 < matches_len => {
|
|
||||||
self.selected_idx = Some(idx + 1);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
self.selected_idx = Some(0);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return currently selected command, if any.
|
/// Return currently selected command, if any.
|
||||||
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
|
pub(crate) fn selected_command(&self) -> Option<&SlashCommand> {
|
||||||
let matches = self.filtered_commands();
|
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 {
|
impl WidgetRef for CommandPopup {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
let matches = self.filtered_commands();
|
let matches = self.filtered();
|
||||||
|
let rows_all: Vec<GenericDisplayRow> = if matches.is_empty() {
|
||||||
let mut rows: Vec<Row> = Vec::new();
|
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),
|
|
||||||
]));
|
|
||||||
} else {
|
} else {
|
||||||
let default_style = Style::default();
|
matches
|
||||||
let command_style = Style::default().fg(Color::LightBlue);
|
.into_iter()
|
||||||
for (idx, cmd) in visible_matches.iter().enumerate() {
|
.map(|(cmd, indices, _)| GenericDisplayRow {
|
||||||
rows.push(Row::new(vec![
|
name: format!("/{}", cmd.command()),
|
||||||
Cell::from(Line::from(vec![
|
match_indices: indices.map(|v| {
|
||||||
if Some(idx) == self.selected_idx {
|
// Shift highlight indices by one to account for the leading '/'
|
||||||
Span::styled(
|
v.into_iter().map(|i| i + 1).collect()
|
||||||
"›",
|
}),
|
||||||
Style::default().bg(Color::DarkGray).fg(Color::LightCyan),
|
is_current: false,
|
||||||
)
|
description: Some(cmd.description().to_string()),
|
||||||
} else {
|
})
|
||||||
Span::styled(QUADRANT_LEFT_HALF, Style::default().fg(Color::DarkGray))
|
.collect()
|
||||||
},
|
};
|
||||||
Span::styled(format!("/{}", cmd.command()), command_style),
|
render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
|
||||||
])),
|
}
|
||||||
Cell::from(cmd.description().to_string()).style(default_style),
|
}
|
||||||
]));
|
|
||||||
|
#[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;
|
assert_eq!(non_empty_rows, 2);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
use ratatui::buffer::Buffer;
|
use ratatui::buffer::Buffer;
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::prelude::Constraint;
|
|
||||||
use ratatui::style::Color;
|
|
||||||
use ratatui::style::Modifier;
|
|
||||||
use ratatui::style::Style;
|
|
||||||
use ratatui::text::Line;
|
|
||||||
use ratatui::text::Span;
|
|
||||||
use ratatui::widgets::Block;
|
|
||||||
use ratatui::widgets::BorderType;
|
|
||||||
use ratatui::widgets::Borders;
|
|
||||||
use ratatui::widgets::Cell;
|
|
||||||
use ratatui::widgets::Row;
|
|
||||||
use ratatui::widgets::Table;
|
|
||||||
use ratatui::widgets::Widget;
|
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
|
|
||||||
/// Maximum number of suggestions shown in the popup.
|
use super::popup_consts::MAX_POPUP_ROWS;
|
||||||
const MAX_RESULTS: usize = 8;
|
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.
|
/// Visual state for the file-search popup.
|
||||||
pub(crate) struct FileSearchPopup {
|
pub(crate) struct FileSearchPopup {
|
||||||
@@ -30,8 +19,8 @@ pub(crate) struct FileSearchPopup {
|
|||||||
waiting: bool,
|
waiting: bool,
|
||||||
/// Cached matches; paths relative to the search dir.
|
/// Cached matches; paths relative to the search dir.
|
||||||
matches: Vec<FileMatch>,
|
matches: Vec<FileMatch>,
|
||||||
/// Currently selected index inside `matches` (if any).
|
/// Shared selection/scroll state.
|
||||||
selected_idx: Option<usize>,
|
state: ScrollState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FileSearchPopup {
|
impl FileSearchPopup {
|
||||||
@@ -41,7 +30,7 @@ impl FileSearchPopup {
|
|||||||
pending_query: String::new(),
|
pending_query: String::new(),
|
||||||
waiting: true,
|
waiting: true,
|
||||||
matches: Vec::new(),
|
matches: Vec::new(),
|
||||||
selected_idx: None,
|
state: ScrollState::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +50,7 @@ impl FileSearchPopup {
|
|||||||
|
|
||||||
if !keep_existing {
|
if !keep_existing {
|
||||||
self.matches.clear();
|
self.matches.clear();
|
||||||
self.selected_idx = None;
|
self.state.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,40 +64,32 @@ impl FileSearchPopup {
|
|||||||
self.display_query = query.to_string();
|
self.display_query = query.to_string();
|
||||||
self.matches = matches;
|
self.matches = matches;
|
||||||
self.waiting = false;
|
self.waiting = false;
|
||||||
self.selected_idx = if self.matches.is_empty() {
|
let len = self.matches.len();
|
||||||
None
|
self.state.clamp_selection(len);
|
||||||
} else {
|
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
|
||||||
Some(0)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move selection cursor up.
|
/// Move selection cursor up.
|
||||||
pub(crate) fn move_up(&mut self) {
|
pub(crate) fn move_up(&mut self) {
|
||||||
if let Some(idx) = self.selected_idx {
|
let len = self.matches.len();
|
||||||
if idx > 0 {
|
self.state.move_up_wrap(len);
|
||||||
self.selected_idx = Some(idx - 1);
|
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Move selection cursor down.
|
/// Move selection cursor down.
|
||||||
pub(crate) fn move_down(&mut self) {
|
pub(crate) fn move_down(&mut self) {
|
||||||
if let Some(idx) = self.selected_idx {
|
let len = self.matches.len();
|
||||||
if idx + 1 < self.matches.len() {
|
self.state.move_down_wrap(len);
|
||||||
self.selected_idx = Some(idx + 1);
|
self.state.ensure_visible(len, len.min(MAX_POPUP_ROWS));
|
||||||
}
|
|
||||||
} else if !self.matches.is_empty() {
|
|
||||||
self.selected_idx = Some(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn selected_match(&self) -> Option<&str> {
|
pub(crate) fn selected_match(&self) -> Option<&str> {
|
||||||
self.selected_idx
|
self.state
|
||||||
|
.selected_idx
|
||||||
.and_then(|idx| self.matches.get(idx))
|
.and_then(|idx| self.matches.get(idx))
|
||||||
.map(|file_match| file_match.path.as_str())
|
.map(|file_match| file_match.path.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Preferred height (rows) including border.
|
|
||||||
pub(crate) fn calculate_required_height(&self) -> u16 {
|
pub(crate) fn calculate_required_height(&self) -> u16 {
|
||||||
// Row count depends on whether we already have matches. If no matches
|
// 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
|
// 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
|
// up to MAX_RESULTS regardless of the waiting flag so the list
|
||||||
// remains stable while a newer search is in-flight.
|
// 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 {
|
impl WidgetRef for &FileSearchPopup {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
// Prepare rows.
|
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
|
||||||
let rows: Vec<Row> = if self.matches.is_empty() {
|
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
|
||||||
vec![Row::new(vec![
|
Vec::new()
|
||||||
Cell::from(if self.waiting {
|
|
||||||
"(searching …)"
|
|
||||||
} else {
|
|
||||||
"no matches"
|
|
||||||
})
|
|
||||||
.style(Style::new().add_modifier(Modifier::ITALIC | Modifier::DIM)),
|
|
||||||
])]
|
|
||||||
} else {
|
} else {
|
||||||
self.matches
|
self.matches
|
||||||
.iter()
|
.iter()
|
||||||
.take(MAX_RESULTS)
|
.map(|m| GenericDisplayRow {
|
||||||
.enumerate()
|
name: m.path.clone(),
|
||||||
.map(|(i, file_match)| {
|
match_indices: m
|
||||||
let FileMatch { path, indices, .. } = file_match;
|
.indices
|
||||||
let path = path.as_str();
|
.as_ref()
|
||||||
#[allow(clippy::expect_used)]
|
.map(|v| v.iter().map(|&i| i as usize).collect()),
|
||||||
let indices = indices.as_ref().expect("indices should be present");
|
is_current: false,
|
||||||
|
description: None,
|
||||||
// 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])
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let table = Table::new(rows, vec![Constraint::Percentage(100)])
|
if self.waiting && rows_all.is_empty() {
|
||||||
.block(
|
// Render a minimal waiting stub using the shared renderer (no rows -> "no matches").
|
||||||
Block::default()
|
render_rows(area, buf, &[], &self.state, MAX_POPUP_ROWS);
|
||||||
.borders(Borders::LEFT)
|
} else {
|
||||||
.border_type(BorderType::QuadrantOutside)
|
render_rows(area, buf, &rows_all, &self.state, MAX_POPUP_ROWS);
|
||||||
.border_style(Style::default().fg(Color::DarkGray)),
|
}
|
||||||
)
|
|
||||||
.widths([Constraint::Percentage(100)]);
|
|
||||||
|
|
||||||
table.render(area, buf);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ mod chat_composer;
|
|||||||
mod chat_composer_history;
|
mod chat_composer_history;
|
||||||
mod command_popup;
|
mod command_popup;
|
||||||
mod file_search_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 status_indicator_view;
|
||||||
mod textarea;
|
mod textarea;
|
||||||
|
|
||||||
@@ -31,6 +36,7 @@ pub(crate) use chat_composer::ChatComposer;
|
|||||||
pub(crate) use chat_composer::InputResult;
|
pub(crate) use chat_composer::InputResult;
|
||||||
|
|
||||||
use approval_modal_view::ApprovalModalView;
|
use approval_modal_view::ApprovalModalView;
|
||||||
|
use codex_core::protocol::AskForApproval;
|
||||||
use status_indicator_view::StatusIndicatorView;
|
use status_indicator_view::StatusIndicatorView;
|
||||||
|
|
||||||
/// Pane displayed in the lower half of the chat UI.
|
/// 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 {
|
pub fn desired_height(&self, width: u16) -> u16 {
|
||||||
self.active_view
|
self.active_view
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -156,9 +179,7 @@ impl BottomPane<'_> {
|
|||||||
ConditionalUpdate::NeedsRedraw => {
|
ConditionalUpdate::NeedsRedraw => {
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
ConditionalUpdate::NoRedraw => {
|
ConditionalUpdate::NoRedraw => {}
|
||||||
// No redraw needed.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +209,6 @@ impl BottomPane<'_> {
|
|||||||
|
|
||||||
match (running, self.active_view.is_some()) {
|
match (running, self.active_view.is_some()) {
|
||||||
(true, false) => {
|
(true, false) => {
|
||||||
// Show status indicator overlay.
|
|
||||||
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
self.active_view = Some(Box::new(StatusIndicatorView::new(
|
||||||
self.app_event_tx.clone(),
|
self.app_event_tx.clone(),
|
||||||
)));
|
)));
|
||||||
@@ -197,17 +217,13 @@ impl BottomPane<'_> {
|
|||||||
(false, true) => {
|
(false, true) => {
|
||||||
if let Some(mut view) = self.active_view.take() {
|
if let Some(mut view) = self.active_view.take() {
|
||||||
if view.should_hide_when_task_is_done() {
|
if view.should_hide_when_task_is_done() {
|
||||||
// Leave self.active_view as None.
|
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
} else {
|
} else {
|
||||||
// Preserve the view.
|
|
||||||
self.active_view = Some(view);
|
self.active_view = Some(view);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {}
|
||||||
// No change.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +301,6 @@ impl BottomPane<'_> {
|
|||||||
|
|
||||||
impl WidgetRef for &BottomPane<'_> {
|
impl WidgetRef for &BottomPane<'_> {
|
||||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||||
// Show BottomPaneView if present.
|
|
||||||
if let Some(ov) = &self.active_view {
|
if let Some(ov) = &self.active_view {
|
||||||
ov.render(area, buf);
|
ov.render(area, buf);
|
||||||
} else {
|
} 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::CodexConversation;
|
||||||
use codex_core::codex_wrapper::init_codex;
|
use codex_core::codex_wrapper::init_codex;
|
||||||
use codex_core::config::Config;
|
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::AgentMessageDeltaEvent;
|
||||||
use codex_core::protocol::AgentMessageEvent;
|
use codex_core::protocol::AgentMessageEvent;
|
||||||
use codex_core::protocol::AgentReasoningDeltaEvent;
|
use codex_core::protocol::AgentReasoningDeltaEvent;
|
||||||
use codex_core::protocol::AgentReasoningEvent;
|
use codex_core::protocol::AgentReasoningEvent;
|
||||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||||
|
use codex_core::protocol::AskForApproval;
|
||||||
use codex_core::protocol::ErrorEvent;
|
use codex_core::protocol::ErrorEvent;
|
||||||
use codex_core::protocol::Event;
|
use codex_core::protocol::Event;
|
||||||
use codex_core::protocol::EventMsg;
|
use codex_core::protocol::EventMsg;
|
||||||
@@ -43,6 +46,7 @@ use crate::history_cell::CommandOutput;
|
|||||||
use crate::history_cell::HistoryCell;
|
use crate::history_cell::HistoryCell;
|
||||||
use crate::history_cell::PatchEventType;
|
use crate::history_cell::PatchEventType;
|
||||||
use crate::user_approval_widget::ApprovalRequest;
|
use crate::user_approval_widget::ApprovalRequest;
|
||||||
|
use codex_core::protocol::SandboxPolicy;
|
||||||
use codex_file_search::FileMatch;
|
use codex_file_search::FileMatch;
|
||||||
|
|
||||||
struct RunningCommand {
|
struct RunningCommand {
|
||||||
@@ -63,7 +67,10 @@ pub(crate) struct ChatWidget<'a> {
|
|||||||
// We wait for the final AgentMessage event and then emit the full text
|
// We wait for the final AgentMessage event and then emit the full text
|
||||||
// at once into scrollback so the history contains a single message.
|
// at once into scrollback so the history contains a single message.
|
||||||
answer_buffer: String,
|
answer_buffer: String,
|
||||||
|
new_session: bool,
|
||||||
running_commands: HashMap<String, RunningCommand>,
|
running_commands: HashMap<String, RunningCommand>,
|
||||||
|
cli_flags_used: Vec<String>,
|
||||||
|
cli_model: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UserMessage {
|
struct UserMessage {
|
||||||
@@ -95,6 +102,8 @@ impl ChatWidget<'_> {
|
|||||||
initial_prompt: Option<String>,
|
initial_prompt: Option<String>,
|
||||||
initial_images: Vec<PathBuf>,
|
initial_images: Vec<PathBuf>,
|
||||||
enhanced_keys_supported: bool,
|
enhanced_keys_supported: bool,
|
||||||
|
cli_flags_used: Vec<String>,
|
||||||
|
cli_model: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
|
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
|
||||||
|
|
||||||
@@ -150,7 +159,10 @@ impl ChatWidget<'_> {
|
|||||||
token_usage: TokenUsage::default(),
|
token_usage: TokenUsage::default(),
|
||||||
reasoning_buffer: String::new(),
|
reasoning_buffer: String::new(),
|
||||||
answer_buffer: String::new(),
|
answer_buffer: String::new(),
|
||||||
|
new_session: true,
|
||||||
running_commands: HashMap::new(),
|
running_commands: HashMap::new(),
|
||||||
|
cli_flags_used,
|
||||||
|
cli_model,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,8 +235,22 @@ impl ChatWidget<'_> {
|
|||||||
EventMsg::SessionConfigured(event) => {
|
EventMsg::SessionConfigured(event) => {
|
||||||
self.bottom_pane
|
self.bottom_pane
|
||||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||||
|
|
||||||
// Record session information at the top of the conversation.
|
// 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 let Some(user_message) = self.initial_user_message.take() {
|
||||||
// If the user provided an initial message, add it to the
|
// 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);
|
.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)> {
|
pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||||
self.bottom_pane.cursor_pos(area)
|
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,
|
config: &Config,
|
||||||
event: SessionConfiguredEvent,
|
event: SessionConfiguredEvent,
|
||||||
is_first_event: bool,
|
is_first_event: bool,
|
||||||
|
cli_flags: Option<&[String]>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let SessionConfiguredEvent {
|
let SessionConfiguredEvent {
|
||||||
model,
|
model,
|
||||||
@@ -199,6 +200,12 @@ impl HistoryCell {
|
|||||||
for (key, value) in entries {
|
for (key, value) in entries {
|
||||||
lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()]));
|
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(""));
|
lines.push(Line::from(""));
|
||||||
HistoryCell::WelcomeMessage {
|
HistoryCell::WelcomeMessage {
|
||||||
view: TextBlock::new(lines),
|
view: TextBlock::new(lines),
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ mod bottom_pane;
|
|||||||
mod chatwidget;
|
mod chatwidget;
|
||||||
mod citation_regex;
|
mod citation_regex;
|
||||||
mod cli;
|
mod cli;
|
||||||
|
mod command_utils;
|
||||||
mod custom_terminal;
|
mod custom_terminal;
|
||||||
|
mod danger_warning_screen;
|
||||||
mod exec_command;
|
mod exec_command;
|
||||||
mod file_search;
|
mod file_search;
|
||||||
mod get_git_diff;
|
mod get_git_diff;
|
||||||
@@ -225,7 +227,42 @@ fn run_ratatui_app(
|
|||||||
terminal.clear()?;
|
terminal.clear()?;
|
||||||
|
|
||||||
let Cli { prompt, images, .. } = cli;
|
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.
|
// 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::IntoEnumIterator;
|
||||||
use strum_macros::AsRefStr;
|
use strum_macros::AsRefStr;
|
||||||
use strum_macros::EnumIter;
|
use strum_macros::EnumIter;
|
||||||
@@ -15,6 +16,8 @@ pub enum SlashCommand {
|
|||||||
New,
|
New,
|
||||||
Compact,
|
Compact,
|
||||||
Diff,
|
Diff,
|
||||||
|
Model,
|
||||||
|
Approvals,
|
||||||
Quit,
|
Quit,
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
TestApproval,
|
TestApproval,
|
||||||
@@ -27,6 +30,8 @@ impl SlashCommand {
|
|||||||
SlashCommand::New => "Start a new chat.",
|
SlashCommand::New => "Start a new chat.",
|
||||||
SlashCommand::Compact => "Compact the chat history.",
|
SlashCommand::Compact => "Compact the chat history.",
|
||||||
SlashCommand::Quit => "Exit the application.",
|
SlashCommand::Quit => "Exit the application.",
|
||||||
|
SlashCommand::Model => "Select the model to use.",
|
||||||
|
SlashCommand::Approvals => "Select the execution mode.",
|
||||||
SlashCommand::Diff => {
|
SlashCommand::Diff => {
|
||||||
"Show git diff of the working directory (including untracked files)"
|
"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)> {
|
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
|
||||||
SlashCommand::iter().map(|c| (c.command(), c)).collect()
|
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