mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
Compare commits
13 Commits
rust-v0.0.
...
release/ru
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2925136536 | ||
|
|
cd2d84d496 | ||
|
|
688100f7f4 | ||
|
|
f30bf4bbcf | ||
|
|
1b7c8d2569 | ||
|
|
4a341efe92 | ||
|
|
e2efe8da9c | ||
|
|
5a0f236ca4 | ||
|
|
ff8ae1ffa1 | ||
|
|
b3ad764532 | ||
|
|
a331a67b3e | ||
|
|
2e293ce903 | ||
|
|
64feeb3803 |
7
.github/workflows/rust-release.yml
vendored
7
.github/workflows/rust-release.yml
vendored
@@ -15,9 +15,6 @@ concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
TAG_REGEX: '^rust-v[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
|
||||
jobs:
|
||||
tag-check:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -33,8 +30,8 @@ jobs:
|
||||
# 1. Must be a tag and match the regex
|
||||
[[ "${GITHUB_REF_TYPE}" == "tag" ]] \
|
||||
|| { echo "❌ Not a tag push"; exit 1; }
|
||||
[[ "${GITHUB_REF_NAME}" =~ ${TAG_REGEX} ]] \
|
||||
|| { echo "❌ Tag '${GITHUB_REF_NAME}' != ${TAG_REGEX}"; exit 1; }
|
||||
[[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \
|
||||
|| { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; }
|
||||
|
||||
# 2. Extract versions
|
||||
tag_ver="${GITHUB_REF_NAME#rust-v}"
|
||||
|
||||
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
@@ -699,6 +699,7 @@ dependencies = [
|
||||
"clap",
|
||||
"ignore",
|
||||
"nucleo-matcher",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
@@ -770,6 +771,7 @@ dependencies = [
|
||||
"codex-ansi-escape",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-file-search",
|
||||
"codex-linux-sandbox",
|
||||
"codex-login",
|
||||
"color-eyre",
|
||||
|
||||
@@ -18,7 +18,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.0"
|
||||
version = "0.2.0-alpha.2"
|
||||
# Track the edition for all workspace crates in one place. Individual
|
||||
# crates can still override this value, but keeping it here means new
|
||||
# crates created with `cargo new -w ...` automatically inherit the 2024
|
||||
@@ -37,3 +37,6 @@ lto = "fat"
|
||||
# Because we bundle some of these executables with the TypeScript CLI, we
|
||||
# remove everything to make the binary as small as possible.
|
||||
strip = "symbols"
|
||||
|
||||
# See https://github.com/openai/codex/issues/1411 for details.
|
||||
codegen-units = 1
|
||||
|
||||
@@ -20,41 +20,11 @@ The model that Codex should use.
|
||||
model = "o3" # overrides the default of "codex-mini-latest"
|
||||
```
|
||||
|
||||
## model_provider
|
||||
|
||||
Codex comes bundled with a number of "model providers" predefined. This config value is a string that indicates which provider to use. You can also define your own providers via `model_providers`.
|
||||
|
||||
For example, if you are running ollama with Mistral locally, then you would need to add the following to your config:
|
||||
|
||||
```toml
|
||||
model = "mistral"
|
||||
model_provider = "ollama"
|
||||
```
|
||||
|
||||
because the following definition for `ollama` is included in Codex:
|
||||
|
||||
```toml
|
||||
[model_providers.ollama]
|
||||
name = "Ollama"
|
||||
base_url = "http://localhost:11434/v1"
|
||||
wire_api = "chat"
|
||||
```
|
||||
|
||||
This option defaults to `"openai"` and the corresponding provider is defined as follows:
|
||||
|
||||
```toml
|
||||
[model_providers.openai]
|
||||
name = "OpenAI"
|
||||
base_url = "https://api.openai.com/v1"
|
||||
env_key = "OPENAI_API_KEY"
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
## model_providers
|
||||
|
||||
This option lets you override and amend the default set of model providers bundled with Codex. This value is a map where the key is the value to use with `model_provider` to select the correspodning provider.
|
||||
This option lets you override and amend the default set of model providers bundled with Codex. This value is a map where the key is the value to use with `model_provider` to select the corresponding provider.
|
||||
|
||||
For example, if you wanted to add a provider that uses the OpenAI 4o model via the chat completions API, then you
|
||||
For example, if you wanted to add a provider that uses the OpenAI 4o model via the chat completions API, then you could add the following configuration:
|
||||
|
||||
```toml
|
||||
# Recall that in TOML, root keys must be listed before tables.
|
||||
@@ -71,10 +41,42 @@ base_url = "https://api.openai.com/v1"
|
||||
# using Codex with this provider. The value of the environment variable must be
|
||||
# non-empty and will be used in the `Bearer TOKEN` HTTP header for the POST request.
|
||||
env_key = "OPENAI_API_KEY"
|
||||
# valid values for wire_api are "chat" and "responses".
|
||||
# Valid values for wire_api are "chat" and "responses".
|
||||
wire_api = "chat"
|
||||
```
|
||||
|
||||
Note this makes it possible to use Codex CLI with non-OpenAI models, so long as they use a wire API that is compatible with the OpenAI chat completions API. For example, you could define the following provider to use Codex CLI with Ollama running locally:
|
||||
|
||||
```toml
|
||||
[model_providers.ollama]
|
||||
name = "Ollama"
|
||||
base_url = "http://localhost:11434/v1"
|
||||
wire_api = "chat"
|
||||
```
|
||||
|
||||
Or a third-party provider (using a distinct environment variable for the API key):
|
||||
|
||||
```toml
|
||||
[model_providers.mistral]
|
||||
name = "Mistral"
|
||||
base_url = "https://api.mistral.ai/v1"
|
||||
env_key = "MISTRAL_API_KEY"
|
||||
wire_api = "chat"
|
||||
```
|
||||
|
||||
## model_provider
|
||||
|
||||
Identifies which provider to use from the `model_providers` map. Defaults to `"openai"`.
|
||||
|
||||
Note that if you override `model_provider`, then you likely want to override
|
||||
`model`, as well. For example, if you are running ollama with Mistral locally,
|
||||
then you would need to add the following to your config in addition to the new entry in the `model_providers` map:
|
||||
|
||||
```toml
|
||||
model = "mistral"
|
||||
model_provider = "ollama"
|
||||
```
|
||||
|
||||
## approval_policy
|
||||
|
||||
Determines when the user should be prompted to approve whether Codex can execute a command:
|
||||
|
||||
@@ -83,6 +83,10 @@ impl ModelProviderInfo {
|
||||
pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
use ModelProviderInfo as P;
|
||||
|
||||
// We do not want to be in the business of adjucating which third-party
|
||||
// providers are bundled with Codex CLI, so we only include the OpenAI
|
||||
// provider by default. Users are encouraged to add to `model_providers`
|
||||
// in config.toml to add their own providers.
|
||||
[
|
||||
(
|
||||
"openai",
|
||||
@@ -94,76 +98,6 @@ pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> {
|
||||
wire_api: WireApi::Responses,
|
||||
},
|
||||
),
|
||||
(
|
||||
"openrouter",
|
||||
P {
|
||||
name: "OpenRouter".into(),
|
||||
base_url: "https://openrouter.ai/api/v1".into(),
|
||||
env_key: Some("OPENROUTER_API_KEY".into()),
|
||||
env_key_instructions: None,
|
||||
wire_api: WireApi::Chat,
|
||||
},
|
||||
),
|
||||
(
|
||||
"gemini",
|
||||
P {
|
||||
name: "Gemini".into(),
|
||||
base_url: "https://generativelanguage.googleapis.com/v1beta/openai".into(),
|
||||
env_key: Some("GEMINI_API_KEY".into()),
|
||||
env_key_instructions: None,
|
||||
wire_api: WireApi::Chat,
|
||||
},
|
||||
),
|
||||
(
|
||||
"ollama",
|
||||
P {
|
||||
name: "Ollama".into(),
|
||||
base_url: "http://localhost:11434/v1".into(),
|
||||
env_key: None,
|
||||
env_key_instructions: None,
|
||||
wire_api: WireApi::Chat,
|
||||
},
|
||||
),
|
||||
(
|
||||
"mistral",
|
||||
P {
|
||||
name: "Mistral".into(),
|
||||
base_url: "https://api.mistral.ai/v1".into(),
|
||||
env_key: Some("MISTRAL_API_KEY".into()),
|
||||
env_key_instructions: None,
|
||||
wire_api: WireApi::Chat,
|
||||
},
|
||||
),
|
||||
(
|
||||
"deepseek",
|
||||
P {
|
||||
name: "DeepSeek".into(),
|
||||
base_url: "https://api.deepseek.com".into(),
|
||||
env_key: Some("DEEPSEEK_API_KEY".into()),
|
||||
env_key_instructions: None,
|
||||
wire_api: WireApi::Chat,
|
||||
},
|
||||
),
|
||||
(
|
||||
"xai",
|
||||
P {
|
||||
name: "xAI".into(),
|
||||
base_url: "https://api.x.ai/v1".into(),
|
||||
env_key: Some("XAI_API_KEY".into()),
|
||||
env_key_instructions: None,
|
||||
wire_api: WireApi::Chat,
|
||||
},
|
||||
),
|
||||
(
|
||||
"groq",
|
||||
P {
|
||||
name: "Groq".into(),
|
||||
base_url: "https://api.groq.com/openai/v1".into(),
|
||||
env_key: Some("GROQ_API_KEY".into()),
|
||||
env_key_instructions: None,
|
||||
wire_api: WireApi::Chat,
|
||||
},
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
|
||||
@@ -16,5 +16,6 @@ anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ignore = "0.4.23"
|
||||
nucleo-matcher = "0.3.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1.0.110"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -20,6 +20,10 @@ pub struct Cli {
|
||||
#[clap(long, short = 'C')]
|
||||
pub cwd: Option<PathBuf>,
|
||||
|
||||
/// Include matching file indices in the output.
|
||||
#[arg(long, default_value = "false")]
|
||||
pub compute_indices: bool,
|
||||
|
||||
// While it is common to default to the number of logical CPUs when creating
|
||||
// a thread pool, empirically, the I/O of the filetree traversal offers
|
||||
// limited parallelism and is the bottleneck, so using a smaller number of
|
||||
|
||||
@@ -6,12 +6,14 @@ use nucleo_matcher::pattern::AtomKind;
|
||||
use nucleo_matcher::pattern::CaseMatching;
|
||||
use nucleo_matcher::pattern::Normalization;
|
||||
use nucleo_matcher::pattern::Pattern;
|
||||
use serde::Serialize;
|
||||
use std::cell::UnsafeCell;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BinaryHeap;
|
||||
use std::num::NonZero;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tokio::process::Command;
|
||||
@@ -20,13 +22,31 @@ mod cli;
|
||||
|
||||
pub use cli::Cli;
|
||||
|
||||
/// A single match result returned from the search.
|
||||
///
|
||||
/// * `score` – Relevance score returned by `nucleo_matcher`.
|
||||
/// * `path` – Path to the matched file (relative to the search directory).
|
||||
/// * `indices` – Optional list of character indices that matched the query.
|
||||
/// These are only filled when the caller of [`run`] sets
|
||||
/// `compute_indices` to `true`. The indices vector follows the
|
||||
/// guidance from `nucleo_matcher::Pattern::indices`: they are
|
||||
/// unique and sorted in ascending order so that callers can use
|
||||
/// them directly for highlighting.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FileMatch {
|
||||
pub score: u32,
|
||||
pub path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub indices: Option<Vec<u32>>, // Sorted & deduplicated when present
|
||||
}
|
||||
|
||||
pub struct FileSearchResults {
|
||||
pub matches: Vec<(u32, String)>,
|
||||
pub matches: Vec<FileMatch>,
|
||||
pub total_match_count: usize,
|
||||
}
|
||||
|
||||
pub trait Reporter {
|
||||
fn report_match(&self, file: &str, score: u32);
|
||||
fn report_match(&self, file_match: &FileMatch);
|
||||
fn warn_matches_truncated(&self, total_match_count: usize, shown_match_count: usize);
|
||||
fn warn_no_search_pattern(&self, search_directory: &Path);
|
||||
}
|
||||
@@ -36,6 +56,7 @@ pub async fn run_main<T: Reporter>(
|
||||
pattern,
|
||||
limit,
|
||||
cwd,
|
||||
compute_indices,
|
||||
json: _,
|
||||
exclude,
|
||||
threads,
|
||||
@@ -72,15 +93,24 @@ pub async fn run_main<T: Reporter>(
|
||||
}
|
||||
};
|
||||
|
||||
let cancel_flag = Arc::new(AtomicBool::new(false));
|
||||
let FileSearchResults {
|
||||
total_match_count,
|
||||
matches,
|
||||
} = run(&pattern_text, limit, search_directory, exclude, threads).await?;
|
||||
} = run(
|
||||
&pattern_text,
|
||||
limit,
|
||||
&search_directory,
|
||||
exclude,
|
||||
threads,
|
||||
cancel_flag,
|
||||
compute_indices,
|
||||
)?;
|
||||
let match_count = matches.len();
|
||||
let matches_truncated = total_match_count > match_count;
|
||||
|
||||
for (score, file) in matches {
|
||||
reporter.report_match(&file, score);
|
||||
for file_match in matches {
|
||||
reporter.report_match(&file_match);
|
||||
}
|
||||
if matches_truncated {
|
||||
reporter.warn_matches_truncated(total_match_count, match_count);
|
||||
@@ -89,12 +119,16 @@ pub async fn run_main<T: Reporter>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
/// The worker threads will periodically check `cancel_flag` to see if they
|
||||
/// should stop processing files.
|
||||
pub fn run(
|
||||
pattern_text: &str,
|
||||
limit: NonZero<usize>,
|
||||
search_directory: PathBuf,
|
||||
search_directory: &Path,
|
||||
exclude: Vec<String>,
|
||||
threads: NonZero<usize>,
|
||||
cancel_flag: Arc<AtomicBool>,
|
||||
compute_indices: bool,
|
||||
) -> anyhow::Result<FileSearchResults> {
|
||||
let pattern = create_pattern(pattern_text);
|
||||
// Create one BestMatchesList per worker thread so that each worker can
|
||||
@@ -116,10 +150,10 @@ pub async fn run(
|
||||
|
||||
// Use the same tree-walker library that ripgrep uses. We use it directly so
|
||||
// that we can leverage the parallelism it provides.
|
||||
let mut walk_builder = WalkBuilder::new(&search_directory);
|
||||
let mut walk_builder = WalkBuilder::new(search_directory);
|
||||
walk_builder.threads(num_walk_builder_threads);
|
||||
if !exclude.is_empty() {
|
||||
let mut override_builder = OverrideBuilder::new(&search_directory);
|
||||
let mut override_builder = OverrideBuilder::new(search_directory);
|
||||
for exclude in exclude {
|
||||
// The `!` prefix is used to indicate an exclude pattern.
|
||||
let exclude_pattern = format!("!{}", exclude);
|
||||
@@ -134,15 +168,28 @@ pub async fn run(
|
||||
// `BestMatchesList` to update.
|
||||
let index_counter = AtomicUsize::new(0);
|
||||
walker.run(|| {
|
||||
let search_directory = search_directory.clone();
|
||||
let index = index_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let best_list_ptr = best_matchers_per_worker[index].get();
|
||||
let best_list = unsafe { &mut *best_list_ptr };
|
||||
|
||||
// Each worker keeps a local counter so we only read the atomic flag
|
||||
// every N entries which is cheaper than checking on every file.
|
||||
const CHECK_INTERVAL: usize = 1024;
|
||||
let mut processed = 0;
|
||||
|
||||
let cancel = cancel_flag.clone();
|
||||
|
||||
Box::new(move |entry| {
|
||||
if let Some(path) = get_file_path(&entry, &search_directory) {
|
||||
if let Some(path) = get_file_path(&entry, search_directory) {
|
||||
best_list.insert(path);
|
||||
}
|
||||
ignore::WalkState::Continue
|
||||
|
||||
processed += 1;
|
||||
if processed % CHECK_INTERVAL == 0 && cancel.load(Ordering::Relaxed) {
|
||||
ignore::WalkState::Quit
|
||||
} else {
|
||||
ignore::WalkState::Continue
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -164,6 +211,14 @@ pub async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
// If the cancel flag is set, we return early with an empty result.
|
||||
if cancel_flag.load(Ordering::Relaxed) {
|
||||
return Ok(FileSearchResults {
|
||||
matches: Vec::new(),
|
||||
total_match_count: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge results across best_matchers_per_worker.
|
||||
let mut global_heap: BinaryHeap<Reverse<(u32, String)>> = BinaryHeap::new();
|
||||
let mut total_match_count = 0;
|
||||
@@ -182,8 +237,41 @@ pub async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
let mut matches: Vec<(u32, String)> = global_heap.into_iter().map(|r| r.0).collect();
|
||||
matches.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mut raw_matches: Vec<(u32, String)> = global_heap.into_iter().map(|r| r.0).collect();
|
||||
sort_matches(&mut raw_matches);
|
||||
|
||||
// Transform into `FileMatch`, optionally computing indices.
|
||||
let mut matcher = if compute_indices {
|
||||
Some(Matcher::new(nucleo_matcher::Config::DEFAULT))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let matches: Vec<FileMatch> = raw_matches
|
||||
.into_iter()
|
||||
.map(|(score, path)| {
|
||||
let indices = if compute_indices {
|
||||
let mut buf = Vec::<char>::new();
|
||||
let haystack: Utf32Str<'_> = Utf32Str::new(&path, &mut buf);
|
||||
let mut idx_vec: Vec<u32> = Vec::new();
|
||||
if let Some(ref mut m) = matcher {
|
||||
// Ignore the score returned from indices – we already have `score`.
|
||||
pattern.indices(haystack, m, &mut idx_vec);
|
||||
}
|
||||
idx_vec.sort_unstable();
|
||||
idx_vec.dedup();
|
||||
Some(idx_vec)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
FileMatch {
|
||||
score,
|
||||
path,
|
||||
indices,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(FileSearchResults {
|
||||
matches,
|
||||
@@ -191,6 +279,14 @@ pub async fn run(
|
||||
})
|
||||
}
|
||||
|
||||
/// Sort matches in-place by descending score, then ascending path.
|
||||
fn sort_matches(matches: &mut [(u32, String)]) {
|
||||
matches.sort_by(|a, b| match b.0.cmp(&a.0) {
|
||||
std::cmp::Ordering::Equal => a.1.cmp(&b.1),
|
||||
other => other,
|
||||
});
|
||||
}
|
||||
|
||||
/// Maintains the `max_count` best matches for a given pattern.
|
||||
struct BestMatchesList {
|
||||
max_count: usize,
|
||||
@@ -281,4 +377,24 @@ mod tests {
|
||||
let score = pattern.score(haystack, &mut matcher);
|
||||
assert_eq!(score, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tie_breakers_sort_by_path_when_scores_equal() {
|
||||
let mut matches = vec![
|
||||
(100, "b_path".to_string()),
|
||||
(100, "a_path".to_string()),
|
||||
(90, "zzz".to_string()),
|
||||
];
|
||||
|
||||
sort_matches(&mut matches);
|
||||
|
||||
// Highest score first; ties broken alphabetically.
|
||||
let expected = vec![
|
||||
(100, "a_path".to_string()),
|
||||
(100, "b_path".to_string()),
|
||||
(90, "zzz".to_string()),
|
||||
];
|
||||
|
||||
assert_eq!(matches, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::io::IsTerminal;
|
||||
use std::path::Path;
|
||||
|
||||
use clap::Parser;
|
||||
use codex_file_search::Cli;
|
||||
use codex_file_search::FileMatch;
|
||||
use codex_file_search::Reporter;
|
||||
use codex_file_search::run_main;
|
||||
use serde_json::json;
|
||||
@@ -11,6 +13,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let reporter = StdioReporter {
|
||||
write_output_as_json: cli.json,
|
||||
show_indices: cli.compute_indices && std::io::stdout().is_terminal(),
|
||||
};
|
||||
run_main(cli, reporter).await?;
|
||||
Ok(())
|
||||
@@ -18,15 +21,40 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
struct StdioReporter {
|
||||
write_output_as_json: bool,
|
||||
show_indices: bool,
|
||||
}
|
||||
|
||||
impl Reporter for StdioReporter {
|
||||
fn report_match(&self, file: &str, score: u32) {
|
||||
fn report_match(&self, file_match: &FileMatch) {
|
||||
if self.write_output_as_json {
|
||||
let value = json!({ "file": file, "score": score });
|
||||
println!("{}", serde_json::to_string(&value).unwrap());
|
||||
println!("{}", serde_json::to_string(&file_match).unwrap());
|
||||
} else if self.show_indices {
|
||||
let indices = file_match
|
||||
.indices
|
||||
.as_ref()
|
||||
.expect("--compute-indices was specified");
|
||||
// `indices` is guaranteed to be sorted in ascending order. Instead
|
||||
// of calling `contains` for every character (which would be O(N^2)
|
||||
// in the worst-case), walk through the `indices` vector once while
|
||||
// iterating over the characters.
|
||||
let mut indices_iter = indices.iter().peekable();
|
||||
|
||||
for (i, c) in file_match.path.chars().enumerate() {
|
||||
match indices_iter.peek() {
|
||||
Some(next) if **next == i as u32 => {
|
||||
// ANSI escape code for bold: \x1b[1m ... \x1b[0m
|
||||
print!("\x1b[1m{}\x1b[0m", c);
|
||||
// advance the iterator since we've consumed this index
|
||||
indices_iter.next();
|
||||
}
|
||||
_ => {
|
||||
print!("{}", c);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!();
|
||||
} else {
|
||||
println!("{file}");
|
||||
println!("{}", file_match.path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# By default, this script uses a version based on the current date and time.
|
||||
# If you want to specify a version, pass it as the first argument. Example:
|
||||
#
|
||||
# ./scripts/create_github_release.sh 0.1.0-alpha.4
|
||||
#
|
||||
# The value will be used to update the `version` field in `Cargo.toml`.
|
||||
|
||||
# Change to the root of the Cargo workspace.
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
|
||||
@@ -15,12 +22,25 @@ fi
|
||||
CURRENT_BRANCH=$(git symbolic-ref --short -q HEAD)
|
||||
|
||||
# Create a new branch for the release and make a commit with the new version.
|
||||
VERSION=$(printf '0.0.%d' "$(date +%y%m%d%H%M)")
|
||||
if [ $# -ge 1 ]; then
|
||||
VERSION="$1"
|
||||
else
|
||||
VERSION=$(printf '0.0.%d' "$(date +%y%m%d%H%M)")
|
||||
fi
|
||||
TAG="rust-v$VERSION"
|
||||
git checkout -b "$TAG"
|
||||
RELEASE_BRANCH="release/$TAG"
|
||||
|
||||
git checkout -b "$RELEASE_BRANCH"
|
||||
perl -i -pe "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml
|
||||
git add Cargo.toml
|
||||
git commit -m "Release $VERSION"
|
||||
git tag -a "$TAG" -m "Release $VERSION"
|
||||
|
||||
# The commit identified by the tag must be reachable from a branch so that
|
||||
# when GitHub creates the `Source code (tar.gz)` for the release, it can find
|
||||
# the commit. This is a requirement for Homebrew to be able to install the
|
||||
# package from the tarball.
|
||||
git push origin "$RELEASE_BRANCH"
|
||||
git push origin "refs/tags/$TAG"
|
||||
|
||||
git checkout "$CURRENT_BRANCH"
|
||||
|
||||
@@ -25,6 +25,7 @@ codex-common = { path = "../common", features = [
|
||||
"elapsed",
|
||||
"sandbox_summary",
|
||||
] }
|
||||
codex-file-search = { path = "../file-search" }
|
||||
codex-linux-sandbox = { path = "../linux-sandbox" }
|
||||
codex-login = { path = "../login" }
|
||||
color-eyre = "0.6.3"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::git_warning_screen::GitWarningOutcome;
|
||||
use crate::git_warning_screen::GitWarningScreen;
|
||||
@@ -11,7 +12,6 @@ use crate::slash_command::SlashCommand;
|
||||
use crate::tui;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::Op;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -44,6 +44,8 @@ pub(crate) struct App<'a> {
|
||||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||||
config: Config,
|
||||
|
||||
file_search: FileSearchManager,
|
||||
|
||||
/// Stored parameters needed to instantiate the ChatWidget later, e.g.,
|
||||
/// after dismissing the Git-repo warning.
|
||||
chat_args: Option<ChatWidgetArgs>,
|
||||
@@ -157,11 +159,13 @@ impl<'a> App<'a> {
|
||||
)
|
||||
};
|
||||
|
||||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||||
Self {
|
||||
app_event_tx,
|
||||
app_event_rx,
|
||||
app_state,
|
||||
config,
|
||||
file_search,
|
||||
chat_args,
|
||||
}
|
||||
}
|
||||
@@ -193,10 +197,11 @@ impl<'a> App<'a> {
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
..
|
||||
} => {
|
||||
// Forward interrupt to ChatWidget when active.
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
widget.submit_op(Op::Interrupt);
|
||||
if widget.on_ctrl_c() {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
}
|
||||
AppState::Login { .. } | AppState::GitWarning { .. } => {
|
||||
// No-op.
|
||||
@@ -273,6 +278,14 @@ impl<'a> App<'a> {
|
||||
}
|
||||
}
|
||||
},
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
self.file_search.on_user_query(query);
|
||||
}
|
||||
AppEvent::FileSearchResult { query, matches } => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.apply_file_search_result(query, matches);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
terminal.clear()?;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use codex_core::protocol::Event;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
use crate::slash_command::SlashCommand;
|
||||
@@ -28,4 +29,17 @@ pub(crate) enum AppEvent {
|
||||
/// Dispatch a recognized slash command from the UI (composer) to the app
|
||||
/// layer so it can be handled centrally.
|
||||
DispatchCommand(SlashCommand),
|
||||
|
||||
/// Kick off an asynchronous file search for the given query (text after
|
||||
/// the `@`). Previous searches may be cancelled by the app layer so there
|
||||
/// is at most one in-flight search.
|
||||
StartFileSearch(String),
|
||||
|
||||
/// Result of a completed asynchronous file search. The `query` echoes the
|
||||
/// original search term so the UI can decide whether the results are
|
||||
/// still relevant.
|
||||
FileSearchResult {
|
||||
query: String,
|
||||
matches: Vec<FileMatch>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,9 +16,11 @@ use tui_textarea::TextArea;
|
||||
|
||||
use super::chat_composer_history::ChatComposerHistory;
|
||||
use super::command_popup::CommandPopup;
|
||||
use super::file_search_popup::FileSearchPopup;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use codex_file_search::FileMatch;
|
||||
|
||||
/// Minimum number of visible text rows inside the textarea.
|
||||
const MIN_TEXTAREA_ROWS: usize = 1;
|
||||
@@ -35,9 +37,19 @@ pub enum InputResult {
|
||||
|
||||
pub(crate) struct ChatComposer<'a> {
|
||||
textarea: TextArea<'a>,
|
||||
command_popup: Option<CommandPopup>,
|
||||
active_popup: ActivePopup,
|
||||
app_event_tx: AppEventSender,
|
||||
history: ChatComposerHistory,
|
||||
ctrl_c_quit_hint: bool,
|
||||
dismissed_file_popup_token: Option<String>,
|
||||
current_file_query: Option<String>,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
enum ActivePopup {
|
||||
None,
|
||||
Command(CommandPopup),
|
||||
File(FileSearchPopup),
|
||||
}
|
||||
|
||||
impl ChatComposer<'_> {
|
||||
@@ -48,9 +60,12 @@ impl ChatComposer<'_> {
|
||||
|
||||
let mut this = Self {
|
||||
textarea,
|
||||
command_popup: None,
|
||||
active_popup: ActivePopup::None,
|
||||
app_event_tx,
|
||||
history: ChatComposerHistory::new(),
|
||||
ctrl_c_quit_hint: false,
|
||||
dismissed_file_popup_token: None,
|
||||
current_file_query: None,
|
||||
};
|
||||
this.update_border(has_input_focus);
|
||||
this
|
||||
@@ -114,24 +129,51 @@ impl ChatComposer<'_> {
|
||||
self.update_border(has_focus);
|
||||
}
|
||||
|
||||
/// Integrate results from an asynchronous file search.
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
// Only apply if user is still editing a token starting with `query`.
|
||||
let current_opt = Self::current_at_token(&self.textarea);
|
||||
let Some(current_token) = current_opt else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !current_token.starts_with(&query) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let ActivePopup::File(popup) = &mut self.active_popup {
|
||||
popup.set_matches(&query, matches);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
|
||||
self.ctrl_c_quit_hint = show;
|
||||
self.update_border(has_focus);
|
||||
}
|
||||
|
||||
/// Handle a key event coming from the main UI.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
let result = match self.command_popup {
|
||||
Some(_) => self.handle_key_event_with_popup(key_event),
|
||||
None => self.handle_key_event_without_popup(key_event),
|
||||
let result = match &mut self.active_popup {
|
||||
ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event),
|
||||
ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event),
|
||||
ActivePopup::None => self.handle_key_event_without_popup(key_event),
|
||||
};
|
||||
|
||||
// Update (or hide/show) popup after processing the key.
|
||||
self.sync_command_popup();
|
||||
if matches!(self.active_popup, ActivePopup::Command(_)) {
|
||||
self.dismissed_file_popup_token = None;
|
||||
} else {
|
||||
self.sync_file_search_popup();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Handle key event when the slash-command popup is visible.
|
||||
fn handle_key_event_with_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
let Some(popup) = self.command_popup.as_mut() else {
|
||||
tracing::error!("handle_key_event_with_popup called without an active popup");
|
||||
return (InputResult::None, false);
|
||||
fn handle_key_event_with_slash_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
let ActivePopup::Command(popup) = &mut self.active_popup else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
match key_event.into() {
|
||||
@@ -179,7 +221,7 @@ impl ChatComposer<'_> {
|
||||
self.textarea.cut();
|
||||
|
||||
// Hide popup since the command has been dispatched.
|
||||
self.command_popup = None;
|
||||
self.active_popup = ActivePopup::None;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
// Fallback to default newline handling if no command selected.
|
||||
@@ -189,6 +231,149 @@ impl ChatComposer<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle key events when file search popup is visible.
|
||||
fn handle_key_event_with_file_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
let ActivePopup::File(popup) = &mut self.active_popup else {
|
||||
unreachable!();
|
||||
};
|
||||
|
||||
match key_event.into() {
|
||||
Input { key: Key::Up, .. } => {
|
||||
popup.move_up();
|
||||
(InputResult::None, true)
|
||||
}
|
||||
Input { key: Key::Down, .. } => {
|
||||
popup.move_down();
|
||||
(InputResult::None, true)
|
||||
}
|
||||
Input { key: Key::Esc, .. } => {
|
||||
// Hide popup without modifying text, remember token to avoid immediate reopen.
|
||||
if let Some(tok) = Self::current_at_token(&self.textarea) {
|
||||
self.dismissed_file_popup_token = Some(tok.to_string());
|
||||
}
|
||||
self.active_popup = ActivePopup::None;
|
||||
(InputResult::None, true)
|
||||
}
|
||||
Input { key: Key::Tab, .. }
|
||||
| Input {
|
||||
key: Key::Enter,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
} => {
|
||||
if let Some(sel) = popup.selected_match() {
|
||||
let sel_path = sel.to_string();
|
||||
// Drop popup borrow before using self mutably again.
|
||||
self.insert_selected_path(&sel_path);
|
||||
self.active_popup = ActivePopup::None;
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
(InputResult::None, false)
|
||||
}
|
||||
input => self.handle_input_basic(input),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the `@token` that the cursor is currently positioned on, if any.
|
||||
///
|
||||
/// The returned string **does not** include the leading `@`.
|
||||
///
|
||||
/// Behavior:
|
||||
/// - The cursor may be anywhere *inside* the token (including on the
|
||||
/// leading `@`). It does **not** need to be at the end of the line.
|
||||
/// - A token is delimited by ASCII whitespace (space, tab, newline).
|
||||
/// - If the token under the cursor starts with `@` and contains at least
|
||||
/// one additional character, that token (without `@`) is returned.
|
||||
fn current_at_token(textarea: &tui_textarea::TextArea) -> Option<String> {
|
||||
let (row, col) = textarea.cursor();
|
||||
|
||||
// Guard against out-of-bounds rows.
|
||||
let line = textarea.lines().get(row)?.as_str();
|
||||
|
||||
// Clamp the cursor column to the line length to avoid slicing panics
|
||||
// when the cursor is at the end of the line.
|
||||
let col = col.min(line.len());
|
||||
|
||||
// Split the line at the cursor position so we can search for word
|
||||
// boundaries on both sides.
|
||||
let before_cursor = &line[..col];
|
||||
let after_cursor = &line[col..];
|
||||
|
||||
// Find start index (first character **after** the previous whitespace).
|
||||
let start_idx = before_cursor
|
||||
.rfind(|c: char| c.is_whitespace())
|
||||
.map(|idx| idx + 1)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Find end index (first whitespace **after** the cursor position).
|
||||
let end_rel_idx = after_cursor
|
||||
.find(|c: char| c.is_whitespace())
|
||||
.unwrap_or(after_cursor.len());
|
||||
let end_idx = col + end_rel_idx;
|
||||
|
||||
if start_idx >= end_idx {
|
||||
return None;
|
||||
}
|
||||
|
||||
let token = &line[start_idx..end_idx];
|
||||
|
||||
if token.starts_with('@') && token.len() > 1 {
|
||||
Some(token[1..].to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the active `@token` (the one under the cursor) with `path`.
|
||||
///
|
||||
/// The algorithm mirrors `current_at_token` so replacement works no matter
|
||||
/// where the cursor is within the token and regardless of how many
|
||||
/// `@tokens` exist in the line.
|
||||
fn insert_selected_path(&mut self, path: &str) {
|
||||
let (row, col) = self.textarea.cursor();
|
||||
|
||||
// Materialize the textarea lines so we can mutate them easily.
|
||||
let mut lines: Vec<String> = self.textarea.lines().to_vec();
|
||||
|
||||
if let Some(line) = lines.get_mut(row) {
|
||||
let col = col.min(line.len());
|
||||
|
||||
let before_cursor = &line[..col];
|
||||
let after_cursor = &line[col..];
|
||||
|
||||
// Determine token boundaries.
|
||||
let start_idx = before_cursor
|
||||
.rfind(|c: char| c.is_whitespace())
|
||||
.map(|idx| idx + 1)
|
||||
.unwrap_or(0);
|
||||
|
||||
let end_rel_idx = after_cursor
|
||||
.find(|c: char| c.is_whitespace())
|
||||
.unwrap_or(after_cursor.len());
|
||||
let end_idx = col + end_rel_idx;
|
||||
|
||||
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
|
||||
let mut new_line =
|
||||
String::with_capacity(line.len() - (end_idx - start_idx) + path.len() + 1);
|
||||
new_line.push_str(&line[..start_idx]);
|
||||
new_line.push_str(path);
|
||||
new_line.push(' ');
|
||||
new_line.push_str(&line[end_idx..]);
|
||||
|
||||
*line = new_line;
|
||||
|
||||
// Re-populate the textarea.
|
||||
let new_text = lines.join("\n");
|
||||
self.textarea.select_all();
|
||||
self.textarea.cut();
|
||||
let _ = self.textarea.insert_str(new_text);
|
||||
|
||||
// Note: tui-textarea currently exposes only relative cursor
|
||||
// movements. Leaving the cursor position unchanged is acceptable
|
||||
// as subsequent typing will move the cursor naturally.
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle key event when no popup is visible.
|
||||
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
let input: Input = key_event.into();
|
||||
@@ -273,25 +458,67 @@ impl ChatComposer<'_> {
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if first_line.starts_with('/') {
|
||||
// Create popup lazily when the user starts a slash command.
|
||||
let popup = self.command_popup.get_or_insert_with(CommandPopup::new);
|
||||
|
||||
// Forward *only* the first line since `CommandPopup` only needs
|
||||
// the command token.
|
||||
popup.on_composer_text_change(first_line.to_string());
|
||||
} else if self.command_popup.is_some() {
|
||||
// Remove popup when '/' is no longer the first character.
|
||||
self.command_popup = None;
|
||||
let input_starts_with_slash = first_line.starts_with('/');
|
||||
match &mut self.active_popup {
|
||||
ActivePopup::Command(popup) => {
|
||||
if input_starts_with_slash {
|
||||
popup.on_composer_text_change(first_line.to_string());
|
||||
} else {
|
||||
self.active_popup = ActivePopup::None;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if input_starts_with_slash {
|
||||
let mut command_popup = CommandPopup::new();
|
||||
command_popup.on_composer_text_change(first_line.to_string());
|
||||
self.active_popup = ActivePopup::Command(command_popup);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronize `self.file_search_popup` with the current text in the textarea.
|
||||
/// Note this is only called when self.active_popup is NOT Command.
|
||||
fn sync_file_search_popup(&mut self) {
|
||||
// Determine if there is an @token underneath the cursor.
|
||||
let query = match Self::current_at_token(&self.textarea) {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
self.active_popup = ActivePopup::None;
|
||||
self.dismissed_file_popup_token = None;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// If user dismissed popup for this exact query, don't reopen until text changes.
|
||||
if self.dismissed_file_popup_token.as_ref() == Some(&query) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.app_event_tx
|
||||
.send(AppEvent::StartFileSearch(query.clone()));
|
||||
|
||||
match &mut self.active_popup {
|
||||
ActivePopup::File(popup) => {
|
||||
popup.set_query(&query);
|
||||
}
|
||||
_ => {
|
||||
let mut popup = FileSearchPopup::new();
|
||||
popup.set_query(&query);
|
||||
self.active_popup = ActivePopup::File(popup);
|
||||
}
|
||||
}
|
||||
|
||||
self.current_file_query = Some(query);
|
||||
self.dismissed_file_popup_token = None;
|
||||
}
|
||||
|
||||
pub fn calculate_required_height(&self, area: &Rect) -> u16 {
|
||||
let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS);
|
||||
let num_popup_rows = if let Some(popup) = &self.command_popup {
|
||||
popup.calculate_required_height(area)
|
||||
} else {
|
||||
0
|
||||
let num_popup_rows = match &self.active_popup {
|
||||
ActivePopup::Command(popup) => popup.calculate_required_height(area),
|
||||
ActivePopup::File(popup) => popup.calculate_required_height(area),
|
||||
ActivePopup::None => 0,
|
||||
};
|
||||
|
||||
rows as u16 + BORDER_LINES + num_popup_rows
|
||||
@@ -304,10 +531,17 @@ impl ChatComposer<'_> {
|
||||
}
|
||||
|
||||
let bs = if has_focus {
|
||||
BlockState {
|
||||
right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline")
|
||||
.alignment(Alignment::Right),
|
||||
border_style: Style::default(),
|
||||
if self.ctrl_c_quit_hint {
|
||||
BlockState {
|
||||
right_title: Line::from("Ctrl+C to quit").alignment(Alignment::Right),
|
||||
border_style: Style::default(),
|
||||
}
|
||||
} else {
|
||||
BlockState {
|
||||
right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline")
|
||||
.alignment(Alignment::Right),
|
||||
border_style: Style::default(),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
BlockState {
|
||||
@@ -325,36 +559,62 @@ impl ChatComposer<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn is_command_popup_visible(&self) -> bool {
|
||||
self.command_popup.is_some()
|
||||
pub(crate) fn is_popup_visible(&self) -> bool {
|
||||
match self.active_popup {
|
||||
ActivePopup::Command(_) | ActivePopup::File(_) => true,
|
||||
ActivePopup::None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &ChatComposer<'_> {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
if let Some(popup) = &self.command_popup {
|
||||
let popup_height = popup.calculate_required_height(&area);
|
||||
match &self.active_popup {
|
||||
ActivePopup::Command(popup) => {
|
||||
let popup_height = popup.calculate_required_height(&area);
|
||||
|
||||
// Split the provided rect so that the popup is rendered at the
|
||||
// *top* and the textarea occupies the remaining space below.
|
||||
let popup_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: popup_height.min(area.height),
|
||||
};
|
||||
// Split the provided rect so that the popup is rendered at the
|
||||
// *top* and the textarea occupies the remaining space below.
|
||||
let popup_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: popup_height.min(area.height),
|
||||
};
|
||||
|
||||
let textarea_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y + popup_rect.height,
|
||||
width: area.width,
|
||||
height: area.height.saturating_sub(popup_rect.height),
|
||||
};
|
||||
let textarea_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y + popup_rect.height,
|
||||
width: area.width,
|
||||
height: area.height.saturating_sub(popup_rect.height),
|
||||
};
|
||||
|
||||
popup.render(popup_rect, buf);
|
||||
self.textarea.render(textarea_rect, buf);
|
||||
} else {
|
||||
self.textarea.render(area, buf);
|
||||
popup.render(popup_rect, buf);
|
||||
self.textarea.render(textarea_rect, buf);
|
||||
}
|
||||
ActivePopup::File(popup) => {
|
||||
let popup_height = popup.calculate_required_height(&area);
|
||||
|
||||
let popup_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y,
|
||||
width: area.width,
|
||||
height: popup_height.min(area.height),
|
||||
};
|
||||
|
||||
let textarea_rect = Rect {
|
||||
x: area.x,
|
||||
y: area.y + popup_rect.height,
|
||||
width: area.width,
|
||||
height: area.height.saturating_sub(popup_height),
|
||||
};
|
||||
|
||||
popup.render(popup_rect, buf);
|
||||
self.textarea.render(textarea_rect, buf);
|
||||
}
|
||||
ActivePopup::None => {
|
||||
self.textarea.render(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
188
codex-rs/tui/src/bottom_pane/file_search_popup.rs
Normal file
188
codex-rs/tui/src/bottom_pane/file_search_popup.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use codex_file_search::FileMatch;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Constraint;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Cell;
|
||||
use ratatui::widgets::Row;
|
||||
use ratatui::widgets::Table;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
/// Maximum number of suggestions shown in the popup.
|
||||
const MAX_RESULTS: usize = 8;
|
||||
|
||||
/// Visual state for the file-search popup.
|
||||
pub(crate) struct FileSearchPopup {
|
||||
/// Query corresponding to the `matches` currently shown.
|
||||
display_query: String,
|
||||
/// Latest query typed by the user. May differ from `display_query` when
|
||||
/// a search is still in-flight.
|
||||
pending_query: String,
|
||||
/// When `true` we are still waiting for results for `pending_query`.
|
||||
waiting: bool,
|
||||
/// Cached matches; paths relative to the search dir.
|
||||
matches: Vec<FileMatch>,
|
||||
/// Currently selected index inside `matches` (if any).
|
||||
selected_idx: Option<usize>,
|
||||
}
|
||||
|
||||
impl FileSearchPopup {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
display_query: String::new(),
|
||||
pending_query: String::new(),
|
||||
waiting: true,
|
||||
matches: Vec::new(),
|
||||
selected_idx: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the query and reset state to *waiting*.
|
||||
pub(crate) fn set_query(&mut self, query: &str) {
|
||||
if query == self.pending_query {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if current matches are still relevant.
|
||||
let keep_existing = query.starts_with(&self.display_query);
|
||||
|
||||
self.pending_query.clear();
|
||||
self.pending_query.push_str(query);
|
||||
|
||||
self.waiting = true; // waiting for new results
|
||||
|
||||
if !keep_existing {
|
||||
self.matches.clear();
|
||||
self.selected_idx = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace matches when a `FileSearchResult` arrives.
|
||||
/// Replace matches. Only applied when `query` matches `pending_query`.
|
||||
pub(crate) fn set_matches(&mut self, query: &str, matches: Vec<FileMatch>) {
|
||||
if query != self.pending_query {
|
||||
return; // stale
|
||||
}
|
||||
|
||||
self.display_query = query.to_string();
|
||||
self.matches = matches;
|
||||
self.waiting = false;
|
||||
self.selected_idx = if self.matches.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(0)
|
||||
};
|
||||
}
|
||||
|
||||
/// Move selection cursor up.
|
||||
pub(crate) fn move_up(&mut self) {
|
||||
if let Some(idx) = self.selected_idx {
|
||||
if idx > 0 {
|
||||
self.selected_idx = Some(idx - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move selection cursor down.
|
||||
pub(crate) fn move_down(&mut self) {
|
||||
if let Some(idx) = self.selected_idx {
|
||||
if idx + 1 < self.matches.len() {
|
||||
self.selected_idx = Some(idx + 1);
|
||||
}
|
||||
} else if !self.matches.is_empty() {
|
||||
self.selected_idx = Some(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn selected_match(&self) -> Option<&str> {
|
||||
self.selected_idx
|
||||
.and_then(|idx| self.matches.get(idx))
|
||||
.map(|file_match| file_match.path.as_str())
|
||||
}
|
||||
|
||||
/// Preferred height (rows) including border.
|
||||
pub(crate) fn calculate_required_height(&self, _area: &Rect) -> u16 {
|
||||
// Row count depends on whether we already have matches. If no matches
|
||||
// yet (e.g. initial search or query with no results) reserve a single
|
||||
// row so the popup is still visible. When matches are present we show
|
||||
// up to MAX_RESULTS regardless of the waiting flag so the list
|
||||
// remains stable while a newer search is in-flight.
|
||||
let rows = if self.matches.is_empty() {
|
||||
1
|
||||
} else {
|
||||
self.matches.len().clamp(1, MAX_RESULTS)
|
||||
} as u16;
|
||||
rows + 2 // border
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &FileSearchPopup {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// Prepare rows.
|
||||
let rows: Vec<Row> = if self.matches.is_empty() {
|
||||
vec![Row::new(vec![Cell::from(" no matches ")])]
|
||||
} else {
|
||||
self.matches
|
||||
.iter()
|
||||
.take(MAX_RESULTS)
|
||||
.enumerate()
|
||||
.map(|(i, file_match)| {
|
||||
let FileMatch { path, indices, .. } = file_match;
|
||||
let path = path.as_str();
|
||||
#[allow(clippy::expect_used)]
|
||||
let indices = indices.as_ref().expect("indices should be present");
|
||||
|
||||
// Build spans with bold on matching indices.
|
||||
let mut idx_iter = indices.iter().peekable();
|
||||
let mut spans: Vec<Span> = Vec::with_capacity(path.len());
|
||||
|
||||
for (char_idx, ch) in path.chars().enumerate() {
|
||||
let mut style = Style::default();
|
||||
if idx_iter
|
||||
.peek()
|
||||
.is_some_and(|next| **next == char_idx as u32)
|
||||
{
|
||||
idx_iter.next();
|
||||
style = style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
spans.push(Span::styled(ch.to_string(), style));
|
||||
}
|
||||
|
||||
// Create cell from the spans.
|
||||
let mut cell = Cell::from(Line::from(spans));
|
||||
|
||||
// If selected, also paint yellow.
|
||||
if Some(i) == self.selected_idx {
|
||||
cell = cell.style(Style::default().fg(Color::Yellow));
|
||||
}
|
||||
|
||||
Row::new(vec![cell])
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut title = format!(" @{} ", self.pending_query);
|
||||
if self.waiting {
|
||||
title.push_str(" (searching …)");
|
||||
}
|
||||
|
||||
let table = Table::new(rows, vec![Constraint::Percentage(100)])
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.title(title),
|
||||
)
|
||||
.widths([Constraint::Percentage(100)]);
|
||||
|
||||
table.render(area, buf);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
|
||||
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use bottom_pane_view::ConditionalUpdate;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use bottom_pane_view::BottomPaneView;
|
||||
use bottom_pane_view::ConditionalUpdate;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_file_search::FileMatch;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
mod approval_modal_view;
|
||||
mod bottom_pane_view;
|
||||
mod chat_composer;
|
||||
mod chat_composer_history;
|
||||
mod command_popup;
|
||||
mod file_search_popup;
|
||||
mod status_indicator_view;
|
||||
|
||||
pub(crate) use chat_composer::ChatComposer;
|
||||
@@ -37,6 +38,7 @@ pub(crate) struct BottomPane<'a> {
|
||||
app_event_tx: AppEventSender,
|
||||
has_input_focus: bool,
|
||||
is_task_running: bool,
|
||||
ctrl_c_quit_hint: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct BottomPaneParams {
|
||||
@@ -52,6 +54,7 @@ impl BottomPane<'_> {
|
||||
app_event_tx: params.app_event_tx,
|
||||
has_input_focus: params.has_input_focus,
|
||||
is_task_running: false,
|
||||
ctrl_c_quit_hint: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +103,26 @@ impl BottomPane<'_> {
|
||||
self.composer.set_input_focus(has_focus);
|
||||
}
|
||||
|
||||
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
||||
self.ctrl_c_quit_hint = true;
|
||||
self.composer
|
||||
.set_ctrl_c_quit_hint(true, self.has_input_focus);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn clear_ctrl_c_quit_hint(&mut self) {
|
||||
if self.ctrl_c_quit_hint {
|
||||
self.ctrl_c_quit_hint = false;
|
||||
self.composer
|
||||
.set_ctrl_c_quit_hint(false, self.has_input_focus);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
|
||||
self.ctrl_c_quit_hint
|
||||
}
|
||||
|
||||
pub fn set_task_running(&mut self, running: bool) {
|
||||
self.is_task_running = running;
|
||||
|
||||
@@ -130,6 +153,10 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_task_running(&self) -> bool {
|
||||
self.is_task_running
|
||||
}
|
||||
|
||||
/// Update the *context-window remaining* indicator in the composer. This
|
||||
/// is forwarded directly to the underlying `ChatComposer`.
|
||||
pub(crate) fn set_token_usage(
|
||||
@@ -175,9 +202,9 @@ impl BottomPane<'_> {
|
||||
self.app_event_tx.send(AppEvent::Redraw)
|
||||
}
|
||||
|
||||
/// Returns true when the slash-command popup inside the composer is visible.
|
||||
pub(crate) fn is_command_popup_visible(&self) -> bool {
|
||||
self.active_view.is_none() && self.composer.is_command_popup_visible()
|
||||
/// Returns true when a popup inside the composer is visible.
|
||||
pub(crate) fn is_popup_visible(&self) -> bool {
|
||||
self.active_view.is_none() && self.composer.is_popup_visible()
|
||||
}
|
||||
|
||||
// --- History helpers ---
|
||||
@@ -200,6 +227,11 @@ impl BottomPane<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
self.composer.on_file_search_result(query, matches);
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &BottomPane<'_> {
|
||||
|
||||
@@ -38,6 +38,7 @@ use crate::bottom_pane::InputResult;
|
||||
use crate::conversation_history_widget::ConversationHistoryWidget;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use codex_file_search::FileMatch;
|
||||
|
||||
pub(crate) struct ChatWidget<'a> {
|
||||
app_event_tx: AppEventSender,
|
||||
@@ -138,11 +139,12 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
// Special-case <Tab>: normally toggles focus between history and bottom panes.
|
||||
// However, when the slash-command popup is visible we forward the key
|
||||
// to the bottom pane so it can handle auto-completion.
|
||||
if matches!(key_event.code, crossterm::event::KeyCode::Tab)
|
||||
&& !self.bottom_pane.is_command_popup_visible()
|
||||
&& !self.bottom_pane.is_popup_visible()
|
||||
{
|
||||
self.input_focus = match self.input_focus {
|
||||
InputFocus::HistoryPane => InputFocus::BottomPane,
|
||||
@@ -244,6 +246,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
EventMsg::TaskStarted => {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.set_task_running(true);
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -402,6 +405,27 @@ impl ChatWidget<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Forward file-search results to the bottom pane.
|
||||
pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
self.bottom_pane.on_file_search_result(query, matches);
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C key press.
|
||||
/// Returns true if the key press was handled, false if it was not.
|
||||
/// If the key press was not handled, the caller should handle it (likely by exiting the process).
|
||||
pub(crate) fn on_ctrl_c(&mut self) -> bool {
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.submit_op(Op::Interrupt);
|
||||
false
|
||||
} else if self.bottom_pane.ctrl_c_quit_hint_visible() {
|
||||
true
|
||||
} else {
|
||||
self.bottom_pane.show_ctrl_c_quit_hint();
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward an `Op` directly to codex.
|
||||
pub(crate) fn submit_op(&self, op: Op) {
|
||||
if let Err(e) = self.codex_op_tx.send(op) {
|
||||
|
||||
201
codex-rs/tui/src/file_search.rs
Normal file
201
codex-rs/tui/src/file_search.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
//! Helper that owns the debounce/cancellation logic for `@` file searches.
|
||||
//!
|
||||
//! `ChatComposer` publishes *every* change of the `@token` as
|
||||
//! `AppEvent::StartFileSearch(query)`.
|
||||
//! This struct receives those events and decides when to actually spawn the
|
||||
//! expensive search (handled in the main `App` thread). It tries to ensure:
|
||||
//!
|
||||
//! - Even when the user types long text quickly, they will start seeing results
|
||||
//! after a short delay using an early version of what they typed.
|
||||
//! - At most one search is in-flight at any time.
|
||||
//!
|
||||
//! It works as follows:
|
||||
//!
|
||||
//! 1. First query starts a debounce timer.
|
||||
//! 2. While the timer is pending, the latest query from the user is stored.
|
||||
//! 3. When the timer fires, it is cleared, and a search is done for the most
|
||||
//! recent query.
|
||||
//! 4. If there is a in-flight search that is not a prefix of the latest thing
|
||||
//! the user typed, it is cancelled.
|
||||
|
||||
use codex_file_search as file_search;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
const MAX_FILE_SEARCH_RESULTS: NonZeroUsize = NonZeroUsize::new(8).unwrap();
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
const NUM_FILE_SEARCH_THREADS: NonZeroUsize = NonZeroUsize::new(2).unwrap();
|
||||
|
||||
/// How long to wait after a keystroke before firing the first search when none
|
||||
/// is currently running. Keeps early queries more meaningful.
|
||||
const FILE_SEARCH_DEBOUNCE: Duration = Duration::from_millis(100);
|
||||
|
||||
const ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL: Duration = Duration::from_millis(20);
|
||||
|
||||
/// State machine for file-search orchestration.
|
||||
pub(crate) struct FileSearchManager {
|
||||
/// Unified state guarded by one mutex.
|
||||
state: Arc<Mutex<SearchState>>,
|
||||
|
||||
search_dir: PathBuf,
|
||||
app_tx: AppEventSender,
|
||||
}
|
||||
|
||||
struct SearchState {
|
||||
/// Latest query typed by user (updated every keystroke).
|
||||
latest_query: String,
|
||||
|
||||
/// true if a search is currently scheduled.
|
||||
is_search_scheduled: bool,
|
||||
|
||||
/// If there is an active search, this will be the query being searched.
|
||||
active_search: Option<ActiveSearch>,
|
||||
}
|
||||
|
||||
struct ActiveSearch {
|
||||
query: String,
|
||||
cancellation_token: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl FileSearchManager {
|
||||
pub fn new(search_dir: PathBuf, tx: AppEventSender) -> Self {
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(SearchState {
|
||||
latest_query: String::new(),
|
||||
is_search_scheduled: false,
|
||||
active_search: None,
|
||||
})),
|
||||
search_dir,
|
||||
app_tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Call whenever the user edits the `@` token.
|
||||
pub fn on_user_query(&self, query: String) {
|
||||
{
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let mut st = self.state.lock().unwrap();
|
||||
if query == st.latest_query {
|
||||
// No change, nothing to do.
|
||||
return;
|
||||
}
|
||||
|
||||
// Update latest query.
|
||||
st.latest_query.clear();
|
||||
st.latest_query.push_str(&query);
|
||||
|
||||
// If there is an in-flight search that is definitely obsolete,
|
||||
// cancel it now.
|
||||
if let Some(active_search) = &st.active_search {
|
||||
if !query.starts_with(&active_search.query) {
|
||||
active_search
|
||||
.cancellation_token
|
||||
.store(true, Ordering::Relaxed);
|
||||
st.active_search = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule a search to run after debounce.
|
||||
if !st.is_search_scheduled {
|
||||
st.is_search_scheduled = true;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we are here, we set `st.is_search_scheduled = true` before
|
||||
// dropping the lock. This means we are the only thread that can spawn a
|
||||
// debounce timer.
|
||||
let state = self.state.clone();
|
||||
let search_dir = self.search_dir.clone();
|
||||
let tx_clone = self.app_tx.clone();
|
||||
thread::spawn(move || {
|
||||
// Always do a minimum debounce, but then poll until the
|
||||
// `active_search` is cleared.
|
||||
thread::sleep(FILE_SEARCH_DEBOUNCE);
|
||||
loop {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
if state.lock().unwrap().active_search.is_none() {
|
||||
break;
|
||||
}
|
||||
thread::sleep(ACTIVE_SEARCH_COMPLETE_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
// The debounce timer has expired, so start a search using the
|
||||
// latest query.
|
||||
let cancellation_token = Arc::new(AtomicBool::new(false));
|
||||
let token = cancellation_token.clone();
|
||||
let query = {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let mut st = state.lock().unwrap();
|
||||
let query = st.latest_query.clone();
|
||||
st.is_search_scheduled = false;
|
||||
st.active_search = Some(ActiveSearch {
|
||||
query: query.clone(),
|
||||
cancellation_token: token,
|
||||
});
|
||||
query
|
||||
};
|
||||
|
||||
FileSearchManager::spawn_file_search(
|
||||
query,
|
||||
search_dir,
|
||||
tx_clone,
|
||||
cancellation_token,
|
||||
state,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn spawn_file_search(
|
||||
query: String,
|
||||
search_dir: PathBuf,
|
||||
tx: AppEventSender,
|
||||
cancellation_token: Arc<AtomicBool>,
|
||||
search_state: Arc<Mutex<SearchState>>,
|
||||
) {
|
||||
let compute_indices = true;
|
||||
std::thread::spawn(move || {
|
||||
let matches = file_search::run(
|
||||
&query,
|
||||
MAX_FILE_SEARCH_RESULTS,
|
||||
&search_dir,
|
||||
Vec::new(),
|
||||
NUM_FILE_SEARCH_THREADS,
|
||||
cancellation_token.clone(),
|
||||
compute_indices,
|
||||
)
|
||||
.map(|res| res.matches)
|
||||
.unwrap_or_default();
|
||||
|
||||
let is_cancelled = cancellation_token.load(Ordering::Relaxed);
|
||||
if !is_cancelled {
|
||||
tx.send(AppEvent::FileSearchResult { query, matches });
|
||||
}
|
||||
|
||||
// Reset the active search state. Do a pointer comparison to verify
|
||||
// that we are clearing the ActiveSearch that corresponds to the
|
||||
// cancellation token we were given.
|
||||
{
|
||||
#[allow(clippy::unwrap_used)]
|
||||
let mut st = search_state.lock().unwrap();
|
||||
if let Some(active_search) = &st.active_search {
|
||||
if Arc::ptr_eq(&active_search.cancellation_token, &cancellation_token) {
|
||||
st.active_search = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ mod citation_regex;
|
||||
mod cli;
|
||||
mod conversation_history_widget;
|
||||
mod exec_command;
|
||||
mod file_search;
|
||||
mod get_git_diff;
|
||||
mod git_warning_screen;
|
||||
mod history_cell;
|
||||
|
||||
Reference in New Issue
Block a user