This commit is contained in:
easong-openai
2025-08-04 11:47:15 -07:00
parent 8e363f0dbc
commit f2ad7ec313
7 changed files with 18 additions and 127 deletions

View File

@@ -14,13 +14,9 @@ pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
return Some((Vec::new(), i32::MAX));
}
// Build a lowercased view of the haystack alongside a mapping from the
// lowered-character indices back to the ORIGINAL character indices.
let mut lowered_chars: Vec<char> = Vec::new();
let mut lowered_to_orig_char_idx: Vec<usize> = Vec::new();
for (orig_idx, ch) in haystack.chars().enumerate() {
// Lowercasing may yield multiple chars; map each back to the same
// original char index so we can highlight correctly.
for lc in ch.to_lowercase() {
lowered_chars.push(lc);
lowered_to_orig_char_idx.push(orig_idx);
@@ -29,28 +25,24 @@ pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
let lowered_needle: Vec<char> = needle.to_lowercase().chars().collect();
// Greedy subsequence match over the lowered chars.
let mut result_orig_indices: Vec<usize> = Vec::with_capacity(lowered_needle.len());
let mut last_lower_pos: Option<usize> = None;
let mut cur = 0usize; // current search start in lowered_chars
let mut cur = 0usize;
for &nc in lowered_needle.iter() {
let mut found_at: Option<usize> = None;
while cur < lowered_chars.len() {
if lowered_chars[cur] == nc {
found_at = Some(cur);
cur += 1; // next search starts after this position
cur += 1;
break;
}
cur += 1;
}
let pos = found_at?;
// Map the lowered pos back to the original character index.
result_orig_indices.push(lowered_to_orig_char_idx[pos]);
last_lower_pos = Some(pos);
}
// Score: window length (in lowered char positions) minus lowered needle
// length (tighter is better), with a strong bonus for prefix match.
let first_lower_pos = if result_orig_indices.is_empty() {
0usize
} else {
@@ -65,10 +57,9 @@ pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
(last_lower_pos as i32 - first_lower_pos as i32 + 1) - (lowered_needle.len() as i32);
let mut score = window.max(0);
if first_lower_pos == 0 {
score -= 100; // strong bonus for prefix match
score -= 100;
}
// Ensure indices are unique and sorted for robust highlighting consumers.
result_orig_indices.sort_unstable();
result_orig_indices.dedup();
Some((result_orig_indices, score))
@@ -77,7 +68,6 @@ pub fn fuzzy_match(haystack: &str, needle: &str) -> Option<(Vec<usize>, i32)> {
/// Convenience wrapper to get only the indices for a fuzzy match.
pub fn fuzzy_indices(haystack: &str, needle: &str) -> Option<Vec<usize>> {
fuzzy_match(haystack, needle).map(|(mut idx, _)| {
// Already sorted/deduped, but keep invariant local to this helper too.
idx.sort_unstable();
idx.dedup();
idx
@@ -99,7 +89,6 @@ mod tests {
#[test]
fn unicode_dotted_i_istanbul_highlighting() {
// 'İ' lowercases to two chars (i + combining dot). Ensure index 0 is highlighted once.
let (idx, _score) = match fuzzy_match("İstanbul", "is") {
Some(v) => v,
None => panic!("expected a match"),
@@ -109,8 +98,6 @@ mod tests {
#[test]
fn unicode_german_sharp_s_casefold() {
// Ensure we at least don't panic with non-ASCII inputs; lowercase-based
// matching does not equate ß with "ss", so this should be None.
assert!(fuzzy_match("straße", "strasse").is_none());
}
}

View File

@@ -187,14 +187,10 @@ impl App<'_> {
let pasted = pasted.replace("\r", "\n");
app_event_tx.send(AppEvent::Paste(pasted));
}
_ => {
// Ignore any other events.
}
_ => {}
}
}
} else {
// Timeout expired, no `Event` is available
}
}
}
});
}
@@ -344,24 +340,19 @@ impl App<'_> {
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
match &mut self.app_state {
AppState::Chat { widget } => {
if widget.composer_is_empty() {
self.app_event_tx.send(AppEvent::ExitRequest);
} else {
// Treat Ctrl+D as a normal key event when the composer
// is not empty so that it doesn't quit the application
// prematurely.
self.dispatch_key_event(key_event);
}
}
AppState::GitWarning { .. } => {
} => match &mut self.app_state {
AppState::Chat { widget } => {
if widget.composer_is_empty() {
self.app_event_tx.send(AppEvent::ExitRequest);
} else {
self.dispatch_key_event(key_event);
}
AppState::DangerWarning { .. } => {}
}
}
AppState::GitWarning { .. } => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
AppState::DangerWarning { .. } => {}
},
KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
@@ -654,9 +645,7 @@ impl App<'_> {
GitWarningOutcome::Quit => {
self.app_event_tx.send(AppEvent::ExitRequest);
}
GitWarningOutcome::None => {
// do nothing
}
GitWarningOutcome::None => {}
},
AppState::DangerWarning { screen, .. } => match screen.handle_key_event(key_event) {
DangerWarningOutcome::Continue => {
@@ -667,8 +656,6 @@ impl App<'_> {
},
);
let _ = ct_execute!(std::io::stdout(), LeaveAlternateScreen);
// After leaving the alternate screen, resync our viewport/cursor
// so the chat composer stays anchored at the bottom.
self.fixup_viewport_after_danger = true;
if let AppState::DangerWarning {
mut widget,
@@ -692,8 +679,6 @@ impl App<'_> {
},
);
let _ = ct_execute!(std::io::stdout(), LeaveAlternateScreen);
// After leaving the alternate screen, resync our viewport/cursor
// so the chat composer stays anchored at the bottom.
self.fixup_viewport_after_danger = true;
if let AppState::DangerWarning { widget, .. } = taken {
self.app_state = AppState::Chat { widget };
@@ -748,7 +733,6 @@ mod tests {
("/model another_model", "another_model", "another_model"),
];
for (line, raw_expected, norm_expected) in cases {
// Extract raw args as in chat_composer
let raw = if let Some(stripped) = line.strip_prefix('/') {
let token = stripped.trim_start();
let cmd_token = token.split_whitespace().next().unwrap_or("");
@@ -758,7 +742,6 @@ mod tests {
String::new()
};
assert_eq!(raw, raw_expected, "raw args for '{line}'");
// Normalize as in app dispatch logic
let normalized = strip_surrounding_quotes(&raw).trim().to_string();
assert_eq!(normalized, norm_expected, "normalized args for '{line}'");
}

View File

@@ -238,9 +238,6 @@ impl ChatComposer {
ActivePopup::Selection(SelectionPopup::new_model(current_model, options));
}
}
// If the composer currently contains a `/model` command, initialize the
// popup's query from its arguments. Otherwise, leave the popup visible
// with an empty query.
let first_line_owned = self.first_line().to_string();
if let ParsedSlash::Command { cmd, args } = parse_slash_line(&first_line_owned) {
if cmd == SlashCommand::Model {
@@ -268,7 +265,6 @@ impl ChatComposer {
));
}
}
// Initialize the popup's query from the arguments to `/approvals`, if present.
let first_line_owned = self.first_line().to_string();
if let ParsedSlash::Command { cmd, args } = parse_slash_line(&first_line_owned) {
if cmd == SlashCommand::Approvals {
@@ -366,7 +362,6 @@ impl ChatComposer {
..
} => {
if let Some(cmd) = popup.selected_command() {
// Extract arguments after the command from the first line using the shared parser.
let args_opt = match parse_slash_line(&first_line_owned) {
ParsedSlash::Command {
cmd: parsed_cmd,
@@ -378,28 +373,21 @@ impl ChatComposer {
_ => None,
};
// Send command + args (if any) to the app layer.
self.app_event_tx.send(AppEvent::DispatchCommand {
cmd: *cmd,
args: args_opt,
});
// Clear textarea so no residual text remains.
self.textarea.set_text("");
// Hide popup since the command has been dispatched.
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
}
// No valid selection treat as invalid command: dismiss popup and surface error.
let invalid_token = match parse_slash_line(&first_line_owned) {
ParsedSlash::Command { cmd, .. } => cmd.command().to_string(),
ParsedSlash::Incomplete { token } => token.to_string(),
ParsedSlash::None => String::new(),
};
// Prevent immediate reopen for the same token.
self.dismissed.slash = Some(invalid_token.clone());
self.active_popup = ActivePopup::None;
// Emit an error entry into history so the user understands what happened.
{
use crate::history_cell::HistoryCell;
let message = if invalid_token.is_empty() {
@@ -441,7 +429,6 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Esc, ..
} => {
// Hide popup without modifying text, remember token to avoid immediate reopen.
if let Some(tok) = Self::current_at_token(&self.textarea) {
self.dismissed.file = Some(tok.to_string());
}
@@ -458,7 +445,6 @@ impl ChatComposer {
} => {
if let Some(sel) = popup.selected_match() {
let sel_path = sel.to_string();
// Drop popup borrow before using self mutably again.
self.insert_selected_path(&sel_path);
self.active_popup = ActivePopup::None;
return (InputResult::None, true);
@@ -496,7 +482,6 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Esc, ..
} => {
// Hide selection popup; keep composer content unchanged.
self.active_popup = ActivePopup::None;
(InputResult::None, true)
}
@@ -682,7 +667,6 @@ impl ChatComposer {
.unwrap_or(after_cursor.len());
let end_idx = cursor_offset + end_rel_idx;
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
let mut new_text =
String::with_capacity(text.len() - (end_idx - start_idx) + path.len() + 1);
new_text.push_str(&text[..start_idx]);
@@ -696,11 +680,6 @@ impl ChatComposer {
/// Handle key event when no popup is visible.
fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
match key_event {
// -------------------------------------------------------------
// History navigation (Up / Down) only when the composer is not
// empty or when the cursor is at the correct position, to avoid
// interfering with normal cursor movement.
// -------------------------------------------------------------
KeyEvent {
code: KeyCode::Up | KeyCode::Down,
..
@@ -730,7 +709,6 @@ impl ChatComposer {
let mut text = self.textarea.text().to_string();
self.textarea.set_text("");
// Replace all pending pastes in the text
for (placeholder, actual) in &self.pending_pastes {
if text.contains(placeholder) {
text = text.replace(placeholder, actual);
@@ -751,7 +729,6 @@ impl ChatComposer {
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
// Special handling for backspace on placeholders
if let KeyEvent {
code: KeyCode::Backspace,
..
@@ -761,12 +738,8 @@ impl ChatComposer {
return (InputResult::None, true);
}
}
// Normal input handling
self.textarea.input(input);
let text_after = self.textarea.text();
// Check if any placeholders were removed and remove their corresponding pending pastes
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
@@ -801,9 +774,6 @@ impl ChatComposer {
}
}
/// Synchronize `self.command_popup` with the current text in the
/// textarea. This must be called after every modification that can change
/// the text so the popup is shown/updated/hidden as appropriate.
fn sync_command_popup(&mut self) {
let first_line = self.textarea.text().lines().next().unwrap_or("");
let input_starts_with_slash = first_line.starts_with('/');
@@ -827,8 +797,6 @@ impl ChatComposer {
}
_ => {
if input_starts_with_slash {
// Avoid immediate reopen of the slash popup if it was just dismissed for
// this exact command token.
if self
.dismissed
.slash
@@ -845,10 +813,7 @@ impl ChatComposer {
}
}
/// Synchronize `self.file_search_popup` with the current text in the textarea.
/// Note this is only called when self.active_popup is NOT Command.
fn sync_file_search_popup(&mut self) {
// Determine if there is an @token underneath the cursor.
let query = match Self::current_at_token(&self.textarea) {
Some(token) => token,
None => {
@@ -857,8 +822,6 @@ impl ChatComposer {
return;
}
};
// If user dismissed popup for this exact query, don't reopen until text changes.
if self.dismissed.file.as_ref() == Some(&query) {
return;
}
@@ -881,13 +844,6 @@ impl ChatComposer {
self.dismissed.file = None;
}
/// Synchronize the selection popup filter with the current composer text.
///
/// When a selection popup is open, we want typing to filter the visible
/// options. If the user is typing a slash command (e.g. `/model o3` or
/// `/approvals auto`), we use only the arguments after the command token
/// as the filter. Otherwise, we treat the entire first line as the filter
/// so that typing freeform text narrows the list as well.
fn sync_selection_popup(&mut self) {
let first_line_owned = self.first_line().to_string();
match (&mut self.active_popup, parse_slash_line(&first_line_owned)) {
@@ -897,13 +853,10 @@ impl ChatComposer {
SelectionKind::Model if cmd == SlashCommand::Model => popup.set_query(args),
SelectionKind::Execution if cmd == SlashCommand::Approvals => popup.set_query(args),
_ => {
// Command present but not relevant to the open selector
// fall back to using the freeform text as the query.
popup.set_query(first_line_owned.trim());
}
},
(ActivePopup::Selection(popup), _no_slash_cmd) => {
// No slash command present use whatever is typed as the query.
popup.set_query(first_line_owned.trim());
}
_ => {}

View File

@@ -179,9 +179,7 @@ impl BottomPane<'_> {
ConditionalUpdate::NeedsRedraw => {
self.request_redraw();
}
ConditionalUpdate::NoRedraw => {
// No redraw needed.
}
ConditionalUpdate::NoRedraw => {}
}
}
}
@@ -211,7 +209,6 @@ impl BottomPane<'_> {
match (running, self.active_view.is_some()) {
(true, false) => {
// Show status indicator overlay.
self.active_view = Some(Box::new(StatusIndicatorView::new(
self.app_event_tx.clone(),
)));
@@ -220,17 +217,13 @@ impl BottomPane<'_> {
(false, true) => {
if let Some(mut view) = self.active_view.take() {
if view.should_hide_when_task_is_done() {
// Leave self.active_view as None.
self.request_redraw();
} else {
// Preserve the view.
self.active_view = Some(view);
}
}
}
_ => {
// No change.
}
_ => {}
}
}
@@ -308,7 +301,6 @@ impl BottomPane<'_> {
impl WidgetRef for &BottomPane<'_> {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Show BottomPaneView if present.
if let Some(ov) = &self.active_view {
ov.render(area, buf);
} else {

View File

@@ -99,7 +99,6 @@ mod tests {
s.ensure_visible(len, vis);
assert_eq!(s.scroll_top, 0);
// Move up wraps to end
s.move_up_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(len - 1));
@@ -108,7 +107,6 @@ mod tests {
None => panic!("expected Some(selected_idx) after wrap"),
}
// Move down wraps to start
s.move_down_wrap(len);
s.ensure_visible(len, vis);
assert_eq!(s.selected_idx, Some(0));

View File

@@ -112,7 +112,6 @@ impl<T: Clone> SelectionList<T> {
pub fn visible_rows(&self) -> Vec<(GenericDisplayRow, Option<&T>)> {
let query = self.query.trim();
// Helper to convert an item to a GenericDisplayRow with optional match indices.
let to_row = |it: &SelectionItem<T>, match_indices: Option<Vec<usize>>| GenericDisplayRow {
name: it.name.clone(),
match_indices,
@@ -128,9 +127,6 @@ impl<T: Clone> SelectionList<T> {
.collect();
}
// Fuzzy search across names and aliases; sort by score (smaller is better),
// then by name for a stable ordering. If the current item matches, include
// it with indices so the UI can highlight matches.
let mut out: Vec<(GenericDisplayRow, Option<&T>, i32, usize)> = Vec::new();
for it in self.items.iter() {
@@ -143,7 +139,6 @@ impl<T: Clone> SelectionList<T> {
));
continue;
}
// Try aliases; keep the best score but do not show indices on the primary name.
let mut best_alias_score: Option<i32> = None;
for alias in it.aliases.iter() {
if let Some((_idx, score)) = fuzzy_match(alias, query) {
@@ -155,8 +150,6 @@ impl<T: Clone> SelectionList<T> {
}
}
// Sort: lower score first (fuzzy_match returns smaller score = better),
// then by name asc, then by name length as a final tiebreaker.
out.sort_by(|a, b| {
a.2.cmp(&b.2)
.then_with(|| a.0.name.cmp(&b.0.name))
@@ -176,7 +169,6 @@ mod tests {
#[test]
fn selection_list_query_and_navigation() {
// Build a small list with aliases similar to execution-mode popup.
let items = vec![
SelectionItem::new("a", "Auto".to_string()).with_aliases(vec!["auto".into()]),
SelectionItem::new("u", "Untrusted".to_string()).with_aliases(vec!["untrusted".into()]),
@@ -185,31 +177,25 @@ mod tests {
let mut list = SelectionList::new(items);
// No query shows all, selection clamped to first row.
let rows = list.visible_rows();
assert_eq!(rows.len(), 3);
assert_eq!(list.selected_value(), Some("a"));
// Up wraps to the end.
list.move_up();
assert_eq!(list.selected_value(), Some("r"));
// Down wraps back to the start.
list.move_down();
assert_eq!(list.selected_value(), Some("a"));
// Query by name prefix prefers the tighter match.
list.set_query("auto");
let rows = list.visible_rows();
assert_eq!(rows.len(), 1);
assert_eq!(list.selected_value(), Some("a"));
// Query by alias works too.
list.set_query("read-only");
let rows = list.visible_rows();
assert_eq!(rows.len(), 1);
assert_eq!(list.selected_value(), Some("r"));
// No matches clears selection.
list.set_query("not-a-match");
let rows = list.visible_rows();
assert_eq!(rows.len(), 0);

View File

@@ -94,7 +94,6 @@ impl ExecutionPreset {
/// Strip a single pair of surrounding quotes from the provided string if present.
/// Supports straight and common curly quotes: '…', "…", ‘…’, “…”.
pub fn strip_surrounding_quotes(s: &str) -> &str {
// Opening/closing pairs (note curly quotes differ on each side)
const QUOTE_PAIRS: &[(char, char)] = &[('\"', '\"'), ('\'', '\''), ('“', '”'), ('', '')];
let t = s.trim();
@@ -125,8 +124,6 @@ pub fn execution_mode_label(approval: AskForApproval, sandbox: &SandboxPolicy) -
.unwrap_or("Custom")
}
// removed unused execution_mode_description and cli_flags helpers
/// Parse a free-form token to an execution preset (approval+sandbox).
pub fn parse_execution_mode_token(s: &str) -> Option<(AskForApproval, SandboxPolicy)> {
ExecutionPreset::parse_token(s).map(|p| p.to_policies())
@@ -180,7 +177,6 @@ mod tests {
#[test]
fn execution_preset_round_trip() {
// For each preset, ensure to_policies -> from_policies -> label/description are consistent
let presets = [
ExecutionPreset::ReadOnly,
ExecutionPreset::Untrusted,
@@ -190,13 +186,9 @@ mod tests {
for p in presets {
let (a, s) = p.to_policies();
// Back to preset
assert_eq!(ExecutionPreset::from_policies(a, &s), Some(p));
// Labels and descriptions are non-empty and stable
assert!(!p.label().is_empty());
assert!(!p.description().is_empty());
// Parsing the canonical token yields the same preset
let token = match p {
ExecutionPreset::ReadOnly => "read-only",
ExecutionPreset::Untrusted => "untrusted",