chore: commit pending changes after successful build (./build-fast.sh)

This commit is contained in:
James Peter
2025-08-26 15:56:05 +10:00
parent 370b15e51d
commit 2c6bb4dc1f
9 changed files with 268 additions and 105 deletions
+6 -3
View File
@@ -27,11 +27,14 @@ case "${1:-}" in
-h|--help) usage; exit 0 ;;
esac
# Change to the Rust project root directory
cd codex-rs
# Resolve repository paths relative to this script so absolute invocation works
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
# Change to the Rust project root directory (codex-rs) regardless of caller CWD
cd "${SCRIPT_DIR}/codex-rs"
# Compute repository root (one level up from codex-rs)
REPO_ROOT="$(cd .. && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Use dev-fast profile by default for quick iteration
# Can override with: PROFILE=release ./build-fast.sh
+40 -4
View File
@@ -144,6 +144,7 @@ pub fn run(
limit.get(),
pattern.clone(),
Matcher::new(nucleo_matcher::Config::DEFAULT),
pattern_text.to_string(),
))
})
.collect();
@@ -152,6 +153,9 @@ pub fn run(
// that we can leverage the parallelism it provides.
let mut walk_builder = WalkBuilder::new(search_directory);
walk_builder.threads(num_walk_builder_threads);
// Include everything by default — do NOT honor .gitignore or global ignore files.
// The UI may de-prioritize commonly ignored paths, but completions should see all files.
walk_builder.ignore(false).git_ignore(false).git_global(false).git_exclude(false).hidden(false);
if !exclude.is_empty() {
let mut override_builder = OverrideBuilder::new(search_directory);
for exclude in exclude {
@@ -297,10 +301,12 @@ struct BestMatchesList {
/// Internal buffer for converting strings to UTF-32.
utf32buf: Vec<char>,
/// Original pattern text (for basename substring/prefix bonuses)
needle: String,
}
impl BestMatchesList {
fn new(max_count: usize, pattern: Pattern, matcher: Matcher) -> Self {
fn new(max_count: usize, pattern: Pattern, matcher: Matcher, needle: String) -> Self {
Self {
max_count,
num_matches: 0,
@@ -308,6 +314,7 @@ impl BestMatchesList {
matcher,
binary_heap: BinaryHeap::new(),
utf32buf: Vec::<char>::new(),
needle,
}
}
@@ -318,12 +325,18 @@ impl BestMatchesList {
// non-match, so we can categorically increment the count here.
self.num_matches += 1;
// Apply light penalties to commonly ignored paths so they rank lower
// while still being available in results.
let penalty = path_penalty(line);
let bonus = basename_bonus(line, &self.needle);
let eff_score = score.saturating_add(bonus).saturating_sub(penalty);
if self.binary_heap.len() < self.max_count {
self.binary_heap.push(Reverse((score, line.to_string())));
self.binary_heap.push(Reverse((eff_score, line.to_string())));
} else if let Some(min_element) = self.binary_heap.peek() {
if score > min_element.0.0 {
if eff_score > min_element.0.0 {
self.binary_heap.pop();
self.binary_heap.push(Reverse((score, line.to_string())));
self.binary_heap.push(Reverse((eff_score, line.to_string())));
}
}
}
@@ -363,6 +376,29 @@ fn create_pattern(pattern: &str) -> Pattern {
)
}
fn path_penalty(path: &str) -> u32 {
let mut p: u32 = 0;
// Penalize large deps/build dirs heavily
if path.contains("/node_modules/") { p = p.saturating_add(300); }
if path.contains("/target/") { p = p.saturating_add(240); }
if path.contains("/dist/") || path.contains("/build/") { p = p.saturating_add(120); }
// Penalize hidden segments a bit (except common VCS dirs kept low)
for seg in path.split('/') {
if seg.starts_with('.') && seg != "." && seg != ".." && seg != ".github" { p = p.saturating_add(40); }
}
p
}
fn basename_bonus(path: &str, needle: &str) -> u32 {
if needle.is_empty() { return 0; }
let file = match path.rsplit_once('/') { Some((_, f)) => f, None => path };
let file_l = file.to_lowercase();
let needle_l = needle.to_lowercase();
if file_l.starts_with(&needle_l) { return 400; }
if file_l.contains(&needle_l) { return 150; }
0
}
#[cfg(test)]
mod tests {
use super::*;
+43 -25
View File
@@ -322,39 +322,57 @@ impl App<'_> {
match key_event {
KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => {
// Double‑Esc behavior only when composer is empty
match &mut self.app_state {
AppState::Chat { widget } => {
// 1) First Esc should stop any running task/stream immediately
if widget.is_task_running() {
let _ = widget.on_ctrl_c();
// Unified Esc policy (highest confidence first):
// 1) If there's text, clear it.
// 2) Else if agent is running, stop it.
// 3) Else double‑Esc engages backtrack/edit‑previous.
if let AppState::Chat { widget } = &mut self.app_state {
// If a file-search popup is visible, close it first
// then continue with global Esc policy in the same keypress.
let closed_file_popup = widget.close_file_popup_if_active();
// If any other modal/overlay view is active, do NOT
// run global Esc policy; the view will handle Esc itself.
if !closed_file_popup && widget.has_active_modal_view() {
// Do nothing else; the view consumed Esc.
} else {
let now = Instant::now();
const THRESHOLD: Duration = Duration::from_millis(600);
// Step 1: clear composer text if present.
if !widget.composer_is_empty() {
widget.clear_composer();
// Arm double‑Esc so a quick second Esc proceeds to steps 2/3.
self.last_esc_time = Some(now);
continue;
}
if widget.composer_is_empty() {
const THRESHOLD: Duration = Duration::from_millis(600);
let now = Instant::now();
if let Some(prev) = self.last_esc_time {
if now.duration_since(prev) <= THRESHOLD {
// second Esc: either undo jump-back, or open picker
self.last_esc_time = None;
if widget.has_pending_jump_back() {
widget.undo_jump_back();
} else {
widget.show_edit_previous_picker();
}
continue;
}
}
// first Esc: flash hint
// Step 2: stop agent if running.
if widget.is_task_running() {
let _ = widget.on_ctrl_c();
// Arm double‑Esc so next Esc can trigger backtrack.
self.last_esc_time = Some(now);
widget.show_esc_backtrack_hint();
continue;
}
// Step 3: backtrack via double‑Esc.
if let Some(prev) = self.last_esc_time {
if now.duration_since(prev) <= THRESHOLD {
self.last_esc_time = None;
if widget.has_pending_jump_back() {
widget.undo_jump_back();
} else {
widget.show_edit_previous_picker();
}
continue;
}
}
// First Esc in empty/idle state: show hint and arm timer.
self.last_esc_time = Some(now);
widget.show_esc_backtrack_hint();
continue;
}
_ => {}
}
// Otherwise fall through to normal dispatch
// Otherwise fall through
}
KeyEvent {
code: KeyCode::Char('m'),
+33 -34
View File
@@ -96,7 +96,6 @@ pub(crate) struct ChatComposer {
token_usage_info: Option<TokenUsageInfo>,
has_focus: bool,
has_chat_history: bool,
last_esc_time: Option<std::time::Instant>,
is_task_running: bool,
// Current status message to display when task is running
status_message: String,
@@ -146,7 +145,7 @@ impl ChatComposer {
token_usage_info: None,
has_focus: has_input_focus,
has_chat_history: false,
last_esc_time: None,
// no double‑Esc handling here; App manages Esc policy
is_task_running: false,
status_message: String::from("coding"),
animation_running: None,
@@ -297,11 +296,12 @@ impl ChatComposer {
ActivePopup::File(c) => c.calculate_required_height(),
};
// Calculate actual content height of textarea
// Width adjustments must mirror render exactly:
// In render, we allocate the input block, then compute its inner area (borders = 2),
// then apply a horizontal Margin of 1 on each side (padding = 2). Total = 4 columns.
let content_width = width.saturating_sub(4);
// IMPORTANT: `width` here is the full BottomPane width. When we render, the
// composer is first given an outer horizontal padding of 1 on each side by
// BottomPane (−2), then our input Block adds borders (−2), then we add inner
// horizontal padding via Margin::new(1, 0) (−2). Net: −6 columns.
// To match wrapping exactly, use width−6 for the TextArea content width.
let content_width = width.saturating_sub(6);
let content_lines = self.textarea.desired_height(content_width).max(1); // At least 1 line
// Total input height: content + border (2) only, no vertical padding
@@ -413,6 +413,15 @@ impl ChatComposer {
true
}
/// Clear all composer input and reset transient state like pending pastes
/// and history navigation.
pub(crate) fn clear_text(&mut self) {
self.textarea.set_text("");
self.pending_pastes.clear();
self.history.reset_navigation();
}
/// Integrate results from an asynchronous file search.
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
// Handle one-off Tab-triggered case first: only open if matches exist.
@@ -460,6 +469,17 @@ impl ChatComposer {
self.sync_file_search_popup();
}
/// Close the file-search popup if it is currently active. Returns true if closed.
pub(crate) fn close_file_popup_if_active(&mut self) -> bool {
match self.active_popup {
ActivePopup::File(_) => {
self.active_popup = ActivePopup::None;
true
}
_ => false,
}
}
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
// Any non-Down key clears the sticky flag; handled before popup routing
@@ -895,31 +915,10 @@ impl ChatComposer {
(InputResult::None, false)
}
// -------------------------------------------------------------
// Handle Esc key for double-press clearing
// Handle Esc key — leave to App-level policy (clear/stop/backtrack)
// -------------------------------------------------------------
KeyEvent {
code: KeyCode::Esc, ..
} => {
// Check if this is a double-Esc press
const DOUBLE_ESC_THRESHOLD: std::time::Duration =
std::time::Duration::from_millis(500);
let now = std::time::Instant::now();
if let Some(last_time) = self.last_esc_time {
if now.duration_since(last_time) < DOUBLE_ESC_THRESHOLD
&& !self.textarea.is_empty()
{
// Double-Esc detected, clear the input field
self.textarea.set_text("");
self.pending_pastes.clear();
self.last_esc_time = None;
self.history.reset_navigation();
return (InputResult::None, true);
}
}
// Single Esc - just record the time
self.last_esc_time = Some(now);
KeyEvent { code: KeyCode::Esc, .. } => {
// Do nothing here so App can implement global Esc ordering.
(InputResult::None, false)
}
// -------------------------------------------------------------
@@ -1137,9 +1136,9 @@ impl ChatComposer {
return;
}
// Only trigger file search when the token has at least 2 characters to
// reduce churn while typing the first character.
if query.chars().count() >= 2 {
// Trigger file search as soon as at least 1 character is typed.
// The popup shows an idle hint for an empty query handled above.
if query.chars().count() >= 1 {
self.app_event_tx
.send(AppEvent::StartFileSearch(query.clone()));
}
@@ -3,6 +3,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::layout::Margin;
use ratatui::widgets::WidgetRef;
use ratatui::prelude::Stylize;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
@@ -124,37 +125,36 @@ impl WidgetRef for &FileSearchPopup {
// rows align with the text inside the composer (border + inner pad).
let indented_area = area.inner(Margin::new(2, 0));
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
Vec::new()
} else {
self.matches
.iter()
.map(|m| GenericDisplayRow {
name: m.path.clone(),
match_indices: m
.indices
.as_ref()
.map(|v| v.iter().map(|&i| i as usize).collect()),
is_current: false,
description: None,
// Use default text color for file matches
name_color: None,
})
.collect()
};
let rows_all: Vec<GenericDisplayRow> = self
.matches
.iter()
.map(|m| GenericDisplayRow {
name: m.path.clone(),
match_indices: m
.indices
.as_ref()
.map(|v| v.iter().map(|&i| i as usize).collect()),
is_current: false,
description: None,
// Use default text color for file matches
name_color: None,
})
.collect();
if self.waiting && rows_all.is_empty() {
// Render a minimal waiting stub using the shared renderer (no rows -> "no matches").
render_rows(indented_area, buf, &[], &self.state, MAX_POPUP_ROWS, false);
// Show a friendly "searching…" placeholder instead of "no matches" while waiting
let msg = "searching…";
// Draw centered within the first row of the hint area
let x = indented_area.x;
let y = indented_area.y;
let w = indented_area.width;
let start = x.saturating_add(w.saturating_sub(msg.len() as u16) / 2);
for xi in x..x + w {
buf[(xi, y)].set_char(' ');
}
buf.set_string(start, y, msg, ratatui::style::Style::default().dim());
} else {
render_rows(
indented_area,
buf,
&rows_all,
&self.state,
MAX_POPUP_ROWS,
false,
);
render_rows(indented_area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false);
}
}
}
+18
View File
@@ -230,6 +230,24 @@ impl BottomPane<'_> {
self.request_redraw();
}
/// Clear the composer text and reset transient composer state.
pub(crate) fn clear_composer(&mut self) {
self.composer.clear_text();
self.request_redraw();
}
/// Attempt to close the file-search popup if visible. Returns true if closed.
pub(crate) fn close_file_popup_if_active(&mut self) -> bool {
let closed = self.composer.close_file_popup_if_active();
if closed { self.request_redraw(); }
closed
}
/// True if a modal/overlay view is currently displayed (not the composer popup).
pub(crate) fn has_active_modal_view(&self) -> bool {
self.active_view.is_some()
}
/// Enable or disable compact compose mode. When enabled, the spacer line
/// above the input composer is removed so the history can scroll into that
/// row. This is typically toggled when the user scrolls up.
+18
View File
@@ -3716,6 +3716,24 @@ impl ChatWidget<'_> {
pub(crate) fn has_pending_jump_back(&self) -> bool { self.pending_jump_back.is_some() }
/// Clear the composer text and any pending paste placeholders/history cursors.
pub(crate) fn clear_composer(&mut self) {
self.bottom_pane.clear_composer();
// Mark a height change so layout adjusts immediately if the composer shrinks.
self.height_manager
.borrow_mut()
.record_event(crate::height_manager::HeightEvent::ComposerModeChange);
self.request_redraw();
}
pub(crate) fn close_file_popup_if_active(&mut self) -> bool {
self.bottom_pane.close_file_popup_if_active()
}
pub(crate) fn has_active_modal_view(&self) -> bool {
self.bottom_pane.has_active_modal_view()
}
/// Forward an `Op` directly to codex.
pub(crate) fn submit_op(&self, op: Op) {
if let Err(e) = self.codex_op_tx.send(op) {
+3 -1
View File
@@ -164,8 +164,10 @@ impl FileSearchManager {
) {
let compute_indices = true;
std::thread::spawn(move || {
// Normalize the query: strip leading "./" so it matches repo-relative paths.
let search_query = query.strip_prefix("./").unwrap_or(&query).to_string();
let matches = file_search::run(
&query,
&search_query,
MAX_FILE_SEARCH_RESULTS,
&search_dir,
Vec::new(),
+79 -10
View File
@@ -434,13 +434,64 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
let url_re = EXPL_URL_RE
.get_or_init(|| Regex::new(r"(?i)\bhttps?://[^\s)]+").unwrap());
let dom_re = DOMAIN_RE.get_or_init(|| {
// e.g., apps.shopify.com or foo.example.io/path?x=1
// Conservative bare-domain matcher (no scheme). Examples:
// apps.shopify.com
// foo.example.io/path?x=1
// It intentionally over-matches a bit; we further filter below.
Regex::new(r"\b([a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+(?:/[\w\-./?%&=#]*)?)")
.unwrap()
});
// Trim common trailing punctuation from a detected URL/domain, returning
// (core, trailing). The trailing part will be emitted as normal text (not
// hyperlinked) so that tokens like "example.com." don’t include the period.
fn split_trailing_punct(s: &str) -> (&str, &str) {
let bytes = s.as_bytes();
let mut end = bytes.len();
while end > 0 {
let ch = bytes[end - 1] as char;
if ")]}>'\".,!?;:".contains(ch) { // common trailing punctuation
end -= 1;
continue;
}
break;
}
(&s[..end], &s[end..])
}
// Additional heuristic to avoid false positives like "e.g." or
// "filename.rs" by requiring a well-known TLD. Precision over recall.
fn is_probable_domain(dom: &str) -> bool {
if dom.contains('@') { return false; }
let dot_count = dom.matches('.').count();
if dot_count == 0 { return false; }
// Extract the final label (candidate TLD) and normalize.
let tld = dom
.rsplit_once('.')
.map(|(_, t)| t)
.unwrap_or("")
.trim_matches(|c: char| !c.is_ascii_alphanumeric())
.to_ascii_lowercase();
// Small allowlist of popular TLDs. This intentionally excludes
// language/file extensions like `.rs`, `.ts`, `.php`, etc.
const ALLOWED_TLDS: &[&str] = &[
"com","org","net","edu","gov","io","ai","app","dev","co","us","uk","ca","de","fr","jp","cn","in","au"
];
ALLOWED_TLDS.contains(&tld.as_str())
}
let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
for s in spans {
// Skip autolinking inside inline code spans (we style code with the
// theme's function color). This avoids linking snippets like
// `curl https://example.com` or code identifiers containing dots.
if s.style.fg == Some(crate::colors::function()) {
out.push(s);
continue;
}
let text = s.content.clone();
let mut cursor = 0usize;
let mut changed = false;
@@ -478,9 +529,15 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
span.content = text[cursor..start].to_string().into();
out.push(span);
}
let url = &text[start..end];
out.push(hyperlink_span(url, url, &s));
cursor = end;
let raw = &text[start..end];
let (core, trailing) = split_trailing_punct(raw);
out.push(hyperlink_span(core, core, &s));
if !trailing.is_empty() {
let mut span = s.clone();
span.content = trailing.to_string().into();
out.push(span);
}
cursor = start + core.len() + trailing.len();
changed = true;
continue;
}
@@ -494,15 +551,27 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
span.content = text[cursor..start].to_string().into();
out.push(span);
}
let dom = &text[start..end];
// Skip if it obviously looks like an email or already in a link
if !dom.contains('@') {
let target = format!("https://{dom}");
out.push(hyperlink_span(dom, &target, &s));
cursor = end;
let raw = &text[start..end];
let (core_dom, trailing) = split_trailing_punct(raw);
if is_probable_domain(core_dom) {
let target = format!("https://{core_dom}");
out.push(hyperlink_span(core_dom, &target, &s));
if !trailing.is_empty() {
let mut span = s.clone();
span.content = trailing.to_string().into();
out.push(span);
}
cursor = start + core_dom.len() + trailing.len();
changed = true;
continue;
}
// Not a probable domain; emit raw text
let mut span = s.clone();
span.content = raw.to_string().into();
out.push(span);
cursor = end;
changed = true;
continue;
}
// No more matches