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 ;; -h|--help) usage; exit 0 ;;
esac esac
# Change to the Rust project root directory # Resolve repository paths relative to this script so absolute invocation works
cd codex-rs 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) # 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 # Use dev-fast profile by default for quick iteration
# Can override with: PROFILE=release ./build-fast.sh # Can override with: PROFILE=release ./build-fast.sh
+40 -4
View File
@@ -144,6 +144,7 @@ pub fn run(
limit.get(), limit.get(),
pattern.clone(), pattern.clone(),
Matcher::new(nucleo_matcher::Config::DEFAULT), Matcher::new(nucleo_matcher::Config::DEFAULT),
pattern_text.to_string(),
)) ))
}) })
.collect(); .collect();
@@ -152,6 +153,9 @@ pub fn run(
// that we can leverage the parallelism it provides. // 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); 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() { if !exclude.is_empty() {
let mut override_builder = OverrideBuilder::new(search_directory); let mut override_builder = OverrideBuilder::new(search_directory);
for exclude in exclude { for exclude in exclude {
@@ -297,10 +301,12 @@ struct BestMatchesList {
/// Internal buffer for converting strings to UTF-32. /// Internal buffer for converting strings to UTF-32.
utf32buf: Vec<char>, utf32buf: Vec<char>,
/// Original pattern text (for basename substring/prefix bonuses)
needle: String,
} }
impl BestMatchesList { 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 { Self {
max_count, max_count,
num_matches: 0, num_matches: 0,
@@ -308,6 +314,7 @@ impl BestMatchesList {
matcher, matcher,
binary_heap: BinaryHeap::new(), binary_heap: BinaryHeap::new(),
utf32buf: Vec::<char>::new(), utf32buf: Vec::<char>::new(),
needle,
} }
} }
@@ -318,12 +325,18 @@ impl BestMatchesList {
// non-match, so we can categorically increment the count here. // non-match, so we can categorically increment the count here.
self.num_matches += 1; 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 { 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() { } 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.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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+43 -25
View File
@@ -322,39 +322,57 @@ impl App<'_> {
match key_event { match key_event {
KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => { KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => {
// Double‑Esc behavior only when composer is empty // Unified Esc policy (highest confidence first):
match &mut self.app_state { // 1) If there's text, clear it.
AppState::Chat { widget } => { // 2) Else if agent is running, stop it.
// 1) First Esc should stop any running task/stream immediately // 3) Else double‑Esc engages backtrack/edit‑previous.
if widget.is_task_running() { if let AppState::Chat { widget } = &mut self.app_state {
let _ = widget.on_ctrl_c(); // 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; continue;
} }
if widget.composer_is_empty() { // Step 2: stop agent if running.
const THRESHOLD: Duration = Duration::from_millis(600); if widget.is_task_running() {
let now = Instant::now(); let _ = widget.on_ctrl_c();
if let Some(prev) = self.last_esc_time { // Arm double‑Esc so next Esc can trigger backtrack.
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
self.last_esc_time = Some(now); self.last_esc_time = Some(now);
widget.show_esc_backtrack_hint();
continue; 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 { KeyEvent {
code: KeyCode::Char('m'), code: KeyCode::Char('m'),
+33 -34
View File
@@ -96,7 +96,6 @@ pub(crate) struct ChatComposer {
token_usage_info: Option<TokenUsageInfo>, token_usage_info: Option<TokenUsageInfo>,
has_focus: bool, has_focus: bool,
has_chat_history: bool, has_chat_history: bool,
last_esc_time: Option<std::time::Instant>,
is_task_running: bool, is_task_running: bool,
// Current status message to display when task is running // Current status message to display when task is running
status_message: String, status_message: String,
@@ -146,7 +145,7 @@ impl ChatComposer {
token_usage_info: None, token_usage_info: None,
has_focus: has_input_focus, has_focus: has_input_focus,
has_chat_history: false, has_chat_history: false,
last_esc_time: None, // no double‑Esc handling here; App manages Esc policy
is_task_running: false, is_task_running: false,
status_message: String::from("coding"), status_message: String::from("coding"),
animation_running: None, animation_running: None,
@@ -297,11 +296,12 @@ impl ChatComposer {
ActivePopup::File(c) => c.calculate_required_height(), ActivePopup::File(c) => c.calculate_required_height(),
}; };
// Calculate actual content height of textarea // IMPORTANT: `width` here is the full BottomPane width. When we render, the
// Width adjustments must mirror render exactly: // composer is first given an outer horizontal padding of 1 on each side by
// In render, we allocate the input block, then compute its inner area (borders = 2), // BottomPane (−2), then our input Block adds borders (−2), then we add inner
// then apply a horizontal Margin of 1 on each side (padding = 2). Total = 4 columns. // horizontal padding via Margin::new(1, 0) (−2). Net: −6 columns.
let content_width = width.saturating_sub(4); // 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 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 // Total input height: content + border (2) only, no vertical padding
@@ -413,6 +413,15 @@ impl ChatComposer {
true 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. /// Integrate results from an asynchronous file search.
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) { 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. // Handle one-off Tab-triggered case first: only open if matches exist.
@@ -460,6 +469,17 @@ impl ChatComposer {
self.sync_file_search_popup(); 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. /// 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) {
// Any non-Down key clears the sticky flag; handled before popup routing // Any non-Down key clears the sticky flag; handled before popup routing
@@ -895,31 +915,10 @@ impl ChatComposer {
(InputResult::None, false) (InputResult::None, false)
} }
// ------------------------------------------------------------- // -------------------------------------------------------------
// Handle Esc key for double-press clearing // Handle Esc key — leave to App-level policy (clear/stop/backtrack)
// ------------------------------------------------------------- // -------------------------------------------------------------
KeyEvent { KeyEvent { code: KeyCode::Esc, .. } => {
code: KeyCode::Esc, .. // Do nothing here so App can implement global Esc ordering.
} => {
// 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);
(InputResult::None, false) (InputResult::None, false)
} }
// ------------------------------------------------------------- // -------------------------------------------------------------
@@ -1137,9 +1136,9 @@ impl ChatComposer {
return; return;
} }
// Only trigger file search when the token has at least 2 characters to // Trigger file search as soon as at least 1 character is typed.
// reduce churn while typing the first character. // The popup shows an idle hint for an empty query handled above.
if query.chars().count() >= 2 { if query.chars().count() >= 1 {
self.app_event_tx self.app_event_tx
.send(AppEvent::StartFileSearch(query.clone())); .send(AppEvent::StartFileSearch(query.clone()));
} }
@@ -3,6 +3,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::layout::Margin; use ratatui::layout::Margin;
use ratatui::widgets::WidgetRef; use ratatui::widgets::WidgetRef;
use ratatui::prelude::Stylize;
use super::popup_consts::MAX_POPUP_ROWS; use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState; use super::scroll_state::ScrollState;
@@ -124,37 +125,36 @@ impl WidgetRef for &FileSearchPopup {
// rows align with the text inside the composer (border + inner pad). // rows align with the text inside the composer (border + inner pad).
let indented_area = area.inner(Margin::new(2, 0)); let indented_area = area.inner(Margin::new(2, 0));
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary. // Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() { let rows_all: Vec<GenericDisplayRow> = self
Vec::new() .matches
} else { .iter()
self.matches .map(|m| GenericDisplayRow {
.iter() name: m.path.clone(),
.map(|m| GenericDisplayRow { match_indices: m
name: m.path.clone(), .indices
match_indices: m .as_ref()
.indices .map(|v| v.iter().map(|&i| i as usize).collect()),
.as_ref() is_current: false,
.map(|v| v.iter().map(|&i| i as usize).collect()), description: None,
is_current: false, // Use default text color for file matches
description: None, name_color: None,
// Use default text color for file matches })
name_color: None, .collect();
})
.collect()
};
if self.waiting && rows_all.is_empty() { if self.waiting && rows_all.is_empty() {
// Render a minimal waiting stub using the shared renderer (no rows -> "no matches"). // Show a friendly "searching…" placeholder instead of "no matches" while waiting
render_rows(indented_area, buf, &[], &self.state, MAX_POPUP_ROWS, false); 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 { } else {
render_rows( render_rows(indented_area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false);
indented_area,
buf,
&rows_all,
&self.state,
MAX_POPUP_ROWS,
false,
);
} }
} }
} }
+18
View File
@@ -230,6 +230,24 @@ impl BottomPane<'_> {
self.request_redraw(); 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 /// Enable or disable compact compose mode. When enabled, the spacer line
/// above the input composer is removed so the history can scroll into that /// above the input composer is removed so the history can scroll into that
/// row. This is typically toggled when the user scrolls up. /// 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() } 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. /// Forward an `Op` directly to codex.
pub(crate) fn submit_op(&self, op: Op) { pub(crate) fn submit_op(&self, op: Op) {
if let Err(e) = self.codex_op_tx.send(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; let compute_indices = true;
std::thread::spawn(move || { 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( let matches = file_search::run(
&query, &search_query,
MAX_FILE_SEARCH_RESULTS, MAX_FILE_SEARCH_RESULTS,
&search_dir, &search_dir,
Vec::new(), 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 let url_re = EXPL_URL_RE
.get_or_init(|| Regex::new(r"(?i)\bhttps?://[^\s)]+").unwrap()); .get_or_init(|| Regex::new(r"(?i)\bhttps?://[^\s)]+").unwrap());
let dom_re = DOMAIN_RE.get_or_init(|| { 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\-./?%&=#]*)?)") Regex::new(r"\b([a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+(?:/[\w\-./?%&=#]*)?)")
.unwrap() .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()); let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
for s in spans { 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 text = s.content.clone();
let mut cursor = 0usize; let mut cursor = 0usize;
let mut changed = false; 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(); span.content = text[cursor..start].to_string().into();
out.push(span); out.push(span);
} }
let url = &text[start..end]; let raw = &text[start..end];
out.push(hyperlink_span(url, url, &s)); let (core, trailing) = split_trailing_punct(raw);
cursor = end; 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; changed = true;
continue; continue;
} }
@@ -494,15 +551,27 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
span.content = text[cursor..start].to_string().into(); span.content = text[cursor..start].to_string().into();
out.push(span); out.push(span);
} }
let dom = &text[start..end]; let raw = &text[start..end];
// Skip if it obviously looks like an email or already in a link let (core_dom, trailing) = split_trailing_punct(raw);
if !dom.contains('@') { if is_probable_domain(core_dom) {
let target = format!("https://{dom}"); let target = format!("https://{core_dom}");
out.push(hyperlink_span(dom, &target, &s)); out.push(hyperlink_span(core_dom, &target, &s));
cursor = end; 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; changed = true;
continue; 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 // No more matches