diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index 95f5cbede..35f4a94e1 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -534,6 +534,9 @@ pub struct EditorUiState { /// Non-zero only when the connected catalog is taller than the /// picker's capped height; the host clamps it on wheel input. pub chat_model_picker_scroll: f32, + /// Live text filter for the chat model picker. While the picker + /// is open it owns typed characters, matching the TS search box. + pub chat_model_picker_search: String, /// Index into `chat.available_models` of the model row the cursor /// is over, or `None`. Drives the picker's hover-row tint. pub chat_model_picker_hover: Option, @@ -704,6 +707,7 @@ impl Default for EditorUiState { icon_picker_load_more_request: None, chat_model_picker_open: false, chat_model_picker_scroll: 0.0, + chat_model_picker_search: String::new(), chat_model_picker_hover: None, chat_selected_agent: 0, topbar_traffic_hover: false, diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index 2082535cc..608b42d5b 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -586,6 +586,7 @@ impl EditorState { } self.editor_ui.chat_model_picker_open = false; self.editor_ui.chat_model_picker_scroll = 0.0; + self.editor_ui.chat_model_picker_search.clear(); self.editor_ui.chat_model_picker_hover = None; } diff --git a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs index aff6cfe30..26a94c4b9 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs @@ -1,10 +1,8 @@ //! Model-picker dropdown for the AI chat panel — the upward //! popover that lists discovered models grouped by provider. //! Mirrors the TS `ai-chat-model-selector.tsx` `ModelDropdown` -//! (grouped rows + per-provider brand icon + selected check). -//! -//! Search is intentionally omitted in this slice — the discovered -//! catalogs are short; a search row lands with live CLI re-query. +//! (search row + grouped rows + per-provider brand icon + selected +//! check / badges). use crate::theme::Theme; use crate::widgets::brand_icons::{paint_brand_logo, paint_opencode_logo, BrandLogo}; @@ -20,67 +18,140 @@ pub const MODEL_GROUP_H: f32 = 22.0; pub const MODEL_ROW_H: f32 = 28.0; /// Vertical padding inside the dropdown card (top + bottom each). pub const MODEL_PICKER_PAD_Y: f32 = 6.0; +/// Fixed search strip at the top of the dropdown. +pub const MODEL_SEARCH_H: f32 = 40.0; /// Hard cap on the dropdown's painted height. A connected catalog /// taller than this (e.g. OpenCode's 75+ models) scrolls inside the /// card instead of growing off the top of the screen. pub const MODEL_PICKER_MAX_H: f32 = 320.0; +const MODEL_EMPTY_H: f32 = 44.0; /// Painted height of the dropdown for `models` — the content height /// clamped to [`MODEL_PICKER_MAX_H`]. -pub fn picker_view_height(models: &[ModelEntry]) -> f32 { - picker_content_height(models).min(MODEL_PICKER_MAX_H) +pub fn picker_view_height(models: &[ModelEntry], search: &str) -> f32 { + picker_content_height(models, search).min(MODEL_PICKER_MAX_H) } /// Largest valid scroll offset for `models` — `0` when the content /// already fits inside [`MODEL_PICKER_MAX_H`]. -pub fn max_picker_scroll(models: &[ModelEntry]) -> f32 { - (picker_content_height(models) - MODEL_PICKER_MAX_H).max(0.0) +pub fn max_picker_scroll(models: &[ModelEntry], search: &str) -> f32 { + let view_list_h = (picker_view_height(models, search) - MODEL_SEARCH_H).max(0.0); + (picker_list_height(models, search) - view_list_h).max(0.0) } /// One laid-out row in the dropdown. enum Row { /// Provider group header — carries the provider for its logo. - Header(AgentProvider), + Header { + provider: AgentProvider, + builtin: bool, + }, /// Selectable model — carries its index into the flat list. - Model(usize), + Model { idx: usize, first_in_group: bool }, +} + +fn normalized_query(search: &str) -> String { + search.trim().to_lowercase() +} + +fn is_builtin(entry: &ModelEntry) -> bool { + entry.builtin_provider_id.is_some() || entry.value.starts_with("builtin:") +} + +fn same_group(a: &ModelEntry, b: &ModelEntry) -> bool { + a.provider == b.provider && a.builtin_provider_id == b.builtin_provider_id +} + +fn model_matches(entry: &ModelEntry, q: &str) -> bool { + q.is_empty() + || entry.display_name.to_lowercase().contains(q) + || entry.value.to_lowercase().contains(q) + || provider_label(entry.provider).to_lowercase().contains(q) + || (is_builtin(entry) && "api key".contains(q)) +} + +pub fn visible_model_indices(models: &[ModelEntry], search: &str) -> Vec { + let q = normalized_query(search); + models + .iter() + .enumerate() + .filter_map(|(idx, entry)| model_matches(entry, &q).then_some(idx)) + .collect() } /// Walk the dropdown row layout, invoking `f(row, y, height)` for /// each row top-to-bottom starting at `top`. Paint and hit-test /// both drive off this so they never drift apart. -fn walk_rows(models: &[ModelEntry], top: f32, mut f: impl FnMut(&Row, f32, f32)) { +fn walk_rows(models: &[ModelEntry], search: &str, top: f32, mut f: impl FnMut(&Row, f32, f32)) { let mut y = top + MODEL_PICKER_PAD_Y; - let mut last_provider: Option = None; - for (idx, entry) in models.iter().enumerate() { - if last_provider != Some(entry.provider) { - f(&Row::Header(entry.provider), y, MODEL_GROUP_H); + let visible = visible_model_indices(models, search); + let mut last_idx: Option = None; + for idx in visible { + let entry = &models[idx]; + let first_in_group = last_idx + .map(|prev| !same_group(&models[prev], entry)) + .unwrap_or(true); + if first_in_group { + f( + &Row::Header { + provider: entry.provider, + builtin: is_builtin(entry), + }, + y, + MODEL_GROUP_H, + ); y += MODEL_GROUP_H; - last_provider = Some(entry.provider); } - f(&Row::Model(idx), y, MODEL_ROW_H); + f( + &Row::Model { + idx, + first_in_group, + }, + y, + MODEL_ROW_H, + ); y += MODEL_ROW_H; + last_idx = Some(idx); } } /// Total dropdown height for `models` (group headers + rows + the /// top/bottom padding). -pub fn picker_content_height(models: &[ModelEntry]) -> f32 { - let mut groups = 0usize; - let mut last: Option = None; - for entry in models { - if last != Some(entry.provider) { - groups += 1; - last = Some(entry.provider); - } +pub fn picker_content_height(models: &[ModelEntry], search: &str) -> f32 { + MODEL_SEARCH_H + picker_list_height(models, search) +} + +fn picker_list_height(models: &[ModelEntry], search: &str) -> f32 { + let visible = visible_model_indices(models, search); + if visible.is_empty() { + return MODEL_EMPTY_H; } - groups as f32 * MODEL_GROUP_H + models.len() as f32 * MODEL_ROW_H + MODEL_PICKER_PAD_Y * 2.0 + let mut groups = 0usize; + let mut last_idx: Option = None; + for idx in visible.iter().copied() { + let entry = &models[idx]; + if last_idx + .map(|prev| !same_group(&models[prev], entry)) + .unwrap_or(true) + { + groups += 1; + } + last_idx = Some(idx); + } + groups as f32 * MODEL_GROUP_H + visible.len() as f32 * MODEL_ROW_H + MODEL_PICKER_PAD_Y * 2.0 } /// Map a click inside the dropdown `rect` to the index of the /// model row under it. `None` for a click on a header / padding. /// `scroll` is the dropdown's vertical scroll offset in px — paint /// and hit-test share it so a scrolled row resolves correctly. -pub fn model_at(rect: Rect, point: Point2D, models: &[ModelEntry], scroll: f32) -> Option { +pub fn model_at( + rect: Rect, + point: Point2D, + models: &[ModelEntry], + scroll: f32, + search: &str, +) -> Option { if point.x < rect.origin.x || point.x > rect.origin.x + rect.size.x || point.y < rect.origin.y @@ -88,15 +159,19 @@ pub fn model_at(rect: Rect, point: Point2D, models: &[ModelEntry], scroll: f32) { return None; } + let list_rect = model_list_rect(rect); + if point.y < list_rect.origin.y { + return None; + } let mut hit = None; // Walk from a scroll-shifted origin — the same offset paint // applies via `translate` — then keep only hits whose row band // actually falls inside the (unscrolled) card rect. - walk_rows(models, rect.origin.y - scroll, |row, y, h| { - if let Row::Model(idx) = row { + walk_rows(models, search, list_rect.origin.y - scroll, |row, y, h| { + if let Row::Model { idx, .. } = row { if point.y >= y && point.y < y + h - && point.y >= rect.origin.y + && point.y >= list_rect.origin.y && point.y <= rect.origin.y + rect.size.y { hit = Some(*idx); @@ -106,6 +181,13 @@ pub fn model_at(rect: Rect, point: Point2D, models: &[ModelEntry], scroll: f32) hit } +fn model_list_rect(rect: Rect) -> Rect { + Rect { + origin: Point2D::new(rect.origin.x, rect.origin.y + MODEL_SEARCH_H), + size: Point2D::new(rect.size.x, (rect.size.y - MODEL_SEARCH_H).max(0.0)), + } +} + /// Paint the dropdown card + grouped rows. `selected` is the index /// of the active model (gets a check mark), `hover` the index of the /// row under the cursor (gets a hover wash). `rect` is the painted @@ -119,30 +201,61 @@ pub fn paint_model_picker( selected: usize, scroll: f32, hover: Option, + search: &str, + locale: op_editor_core::Locale, ) { // Card background + border — painted unscrolled so the frame // stays put while the rows scroll inside it. - cx.backend.fill_round_rect(rect, 10.0, theme.popover); + cx.backend.fill_round_rect(rect, 10.0, theme.card); cx.backend.stroke_round_rect(rect, 10.0, theme.border, 1.0); let row_left = rect.origin.x + 12.0; - let row_w = rect.size.x - 12.0; + paint_search_row(cx, theme, rect, search, locale); + let list_rect = model_list_rect(rect); + if visible_model_indices(models, search).is_empty() { + let empty = op_i18n::translate(locale, "ai.noModelsFound"); + let layout = TextLayout::single_run( + empty, + "system-ui", + 12.0, + to_jian_color(theme.muted_foreground), + Point2D::new(0.0, 0.0), + ); + let w = cx.backend.measure_text(empty, 12.0); + cx.backend.draw_text( + &layout, + Point2D::new( + rect.origin.x + (rect.size.x - w) / 2.0, + list_rect.origin.y + 26.0, + ), + ); + return; + } // Clip to the card and shift by `-scroll` so off-card rows are // trimmed and the visible band tracks the scroll offset. cx.backend.save(); - cx.backend.clip_rect(rect); + cx.backend.clip_rect(list_rect); cx.backend.translate(Point2D::new(0.0, -scroll)); - walk_rows(models, rect.origin.y, |row, y, h| match row { - Row::Header(provider) => { + walk_rows(models, search, list_rect.origin.y, |row, y, h| match row { + Row::Header { provider, builtin } => { let logo_y = y + (h - 12.0) / 2.0; - paint_provider_logo( - cx, - *provider, - Point2D::new(row_left, logo_y), - 12.0, - theme.muted_foreground, - ); + if *builtin { + paint_key_glyph( + cx, + Point2D::new(row_left, logo_y), + 12.0, + theme.muted_foreground, + ); + } else { + paint_provider_logo( + cx, + *provider, + Point2D::new(row_left, logo_y), + 12.0, + theme.muted_foreground, + ); + } let label = TextLayout::single_run( - provider_label(*provider), + group_label(*provider, *builtin), "system-ui", 10.0, to_jian_color(theme.muted_foreground), @@ -151,7 +264,10 @@ pub fn paint_model_picker( cx.backend .draw_text(&label, Point2D::new(row_left + 18.0, y + h / 2.0 + 3.0)); } - Row::Model(idx) => { + Row::Model { + idx, + first_in_group, + } => { let is_selected = *idx == selected; let is_hovered = hover == Some(*idx); // Hover wash on any non-selected row the cursor is over; @@ -202,15 +318,33 @@ pub fn paint_model_picker( ); cx.backend .draw_text(&label, Point2D::new(row_left + 22.0, y + h / 2.0 + 4.0)); + if let Some(entry) = models.get(*idx) { + if is_builtin(entry) { + paint_badge( + cx, + theme, + op_i18n::translate(locale, "builtin.apiKeyBadge"), + rect.origin.x + rect.size.x - 12.0, + y + (h - 16.0) / 2.0, + ); + } else if *first_in_group && normalized_query(search).is_empty() { + paint_badge( + cx, + theme, + op_i18n::translate(locale, "common.best"), + rect.origin.x + rect.size.x - 12.0, + y + (h - 16.0) / 2.0, + ); + } + } } }); cx.backend.restore(); - let _ = row_w; // Scrollbar thumb — drawn after `restore()` so it sits in // unscrolled card space. Shown only when the content overflows. - let content_h = picker_content_height(models); - let view_h = rect.size.y; + let content_h = picker_list_height(models, search); + let view_h = list_rect.size.y; if content_h > view_h + 0.5 { let track_h = view_h - 8.0; let thumb_h = (track_h * view_h / content_h).max(24.0); @@ -220,7 +354,7 @@ pub fn paint_model_picker( } else { 0.0 }; - let thumb_y = rect.origin.y + 4.0 + t * (track_h - thumb_h); + let thumb_y = list_rect.origin.y + 4.0 + t * (track_h - thumb_h); let thumb = Rect { origin: Point2D::new(rect.origin.x + rect.size.x - 6.0, thumb_y), size: Point2D::new(3.0, thumb_h), @@ -230,6 +364,116 @@ pub fn paint_model_picker( } } +fn paint_search_row( + cx: &mut PaintCx<'_>, + theme: &Theme, + rect: Rect, + search: &str, + locale: op_editor_core::Locale, +) { + let divider_y = rect.origin.y + MODEL_SEARCH_H - 0.5; + cx.backend.fill_rect( + Rect { + origin: Point2D::new(rect.origin.x, divider_y), + size: Point2D::new(rect.size.x, 1.0), + }, + theme.border, + ); + let search_rect = Rect { + origin: Point2D::new(rect.origin.x + 8.0, rect.origin.y + 7.0), + size: Point2D::new(rect.size.x - 16.0, 24.0), + }; + cx.backend + .fill_round_rect(search_rect, 6.0, with_alpha(theme.muted, 0.5)); + draw_icon( + cx.backend, + Icon::Search, + Point2D::new(search_rect.origin.x + 8.0, search_rect.origin.y + 6.0), + 12.0, + theme.muted_foreground, + 1.4, + ); + let raw = search.trim(); + let (label, color) = if raw.is_empty() { + ( + op_i18n::translate(locale, "ai.searchModels"), + theme.muted_foreground, + ) + } else { + (raw, theme.foreground) + }; + let layout = TextLayout::single_run( + label, + "system-ui", + 12.0, + to_jian_color(color), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &layout, + Point2D::new(search_rect.origin.x + 28.0, search_rect.origin.y + 17.0), + ); + if !raw.is_empty() { + draw_icon( + cx.backend, + Icon::Close, + Point2D::new( + search_rect.origin.x + search_rect.size.x - 18.0, + search_rect.origin.y + 7.0, + ), + 10.0, + theme.muted_foreground, + 1.4, + ); + } +} + +fn paint_badge(cx: &mut PaintCx<'_>, theme: &Theme, text: &str, right_x: f32, y: f32) { + let w = cx.backend.measure_text(text, 9.0) + 8.0; + let rect = Rect { + origin: Point2D::new(right_x - w, y), + size: Point2D::new(w, 16.0), + }; + cx.backend.fill_round_rect(rect, 4.0, theme.muted); + let layout = TextLayout::single_run( + text, + "system-ui", + 9.0, + to_jian_color(theme.muted_foreground), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &layout, + Point2D::new(rect.origin.x + 4.0, rect.origin.y + 11.0), + ); +} + +fn with_alpha(color: Color, a: f32) -> Color { + Color { a, ..color } +} + +fn paint_key_glyph(cx: &mut PaintCx<'_>, top_left: Point2D, size: f32, color: Color) { + let cy = top_left.y + size * 0.5; + let ring = Rect { + origin: Point2D::new(top_left.x, cy - size * 0.28), + size: Point2D::new(size * 0.55, size * 0.55), + }; + cx.backend + .stroke_round_rect(ring, ring.size.x / 2.0, color, 1.3); + cx.backend.stroke_line( + Point2D::new(top_left.x + size * 0.52, cy), + Point2D::new(top_left.x + size, cy), + color, + 1.3, + ); + cx.backend.stroke_line( + Point2D::new(top_left.x + size * 0.82, cy), + Point2D::new(top_left.x + size * 0.82, cy + size * 0.25), + color, + 1.3, + ); +} + /// Paint a provider's brand logo into a `size × size` square. /// OpenCode has no single-path logo, so it routes through the /// multi-primitive `paint_opencode_logo`. @@ -269,6 +513,20 @@ fn provider_label(provider: AgentProvider) -> &'static str { } } +fn group_label(provider: AgentProvider, builtin: bool) -> &'static str { + if builtin { + match provider { + AgentProvider::ClaudeCode => "ANTHROPIC API KEY", + AgentProvider::CodexCli => "OPENAI API KEY", + AgentProvider::GeminiCli => "GEMINI API KEY", + AgentProvider::GithubCopilot => "COPILOT API KEY", + AgentProvider::OpenCode => "OPENCODE API KEY", + } + } else { + provider_label(provider) + } +} + #[cfg(test)] mod tests { use super::*; @@ -285,8 +543,9 @@ mod tests { entry(AgentProvider::CodexCli, "c"), ]; // 2 groups + 3 rows + padding. - let expected = 2.0 * MODEL_GROUP_H + 3.0 * MODEL_ROW_H + MODEL_PICKER_PAD_Y * 2.0; - assert!((picker_content_height(&models) - expected).abs() < 0.01); + let expected = + MODEL_SEARCH_H + 2.0 * MODEL_GROUP_H + 3.0 * MODEL_ROW_H + MODEL_PICKER_PAD_Y * 2.0; + assert!((picker_content_height(&models, "") - expected).abs() < 0.01); } #[test] @@ -297,18 +556,18 @@ mod tests { ]; let rect = Rect { origin: Point2D::new(0.0, 0.0), - size: Point2D::new(200.0, picker_content_height(&models)), + size: Point2D::new(200.0, picker_content_height(&models, "")), }; // First model row sits below the first group header. - let first_row_y = MODEL_PICKER_PAD_Y + MODEL_GROUP_H + MODEL_ROW_H / 2.0; + let first_row_y = MODEL_SEARCH_H + MODEL_PICKER_PAD_Y + MODEL_GROUP_H + MODEL_ROW_H / 2.0; assert_eq!( - model_at(rect, Point2D::new(100.0, first_row_y), &models, 0.0), + model_at(rect, Point2D::new(100.0, first_row_y), &models, 0.0, ""), Some(0) ); // A click on the header band resolves to nothing. - let header_y = MODEL_PICKER_PAD_Y + MODEL_GROUP_H / 2.0; + let header_y = MODEL_SEARCH_H + MODEL_PICKER_PAD_Y + MODEL_GROUP_H / 2.0; assert_eq!( - model_at(rect, Point2D::new(100.0, header_y), &models, 0.0), + model_at(rect, Point2D::new(100.0, header_y), &models, 0.0, ""), None ); } @@ -323,17 +582,43 @@ mod tests { .collect(); let rect = Rect { origin: Point2D::new(0.0, 0.0), - size: Point2D::new(200.0, picker_view_height(&models)), + size: Point2D::new(200.0, picker_view_height(&models, "")), }; let probe = Point2D::new( 100.0, - MODEL_PICKER_PAD_Y + MODEL_GROUP_H + MODEL_ROW_H / 2.0, + MODEL_SEARCH_H + MODEL_PICKER_PAD_Y + MODEL_GROUP_H + MODEL_ROW_H / 2.0, ); - let unscrolled = model_at(rect, probe, &models, 0.0); - let scrolled = model_at(rect, probe, &models, MODEL_ROW_H * 3.0); + let unscrolled = model_at(rect, probe, &models, 0.0, ""); + let scrolled = model_at(rect, probe, &models, MODEL_ROW_H * 3.0, ""); assert_eq!(unscrolled, Some(0)); assert_eq!(scrolled, Some(3)); // The catalog overflows the cap, so scrolling is possible. - assert!(max_picker_scroll(&models) > 0.0); + assert!(max_picker_scroll(&models, "") > 0.0); + } + + #[test] + fn model_at_filters_by_search_and_returns_original_index() { + let models = vec![ + entry(AgentProvider::ClaudeCode, "opus"), + entry(AgentProvider::CodexCli, "gpt-5.5"), + entry(AgentProvider::CodexCli, "gpt-4.1"), + ]; + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(220.0, picker_view_height(&models, "5.5")), + }; + let first_filtered_row_y = + MODEL_SEARCH_H + MODEL_PICKER_PAD_Y + MODEL_GROUP_H + MODEL_ROW_H / 2.0; + + assert_eq!( + model_at( + rect, + Point2D::new(100.0, first_filtered_row_y), + &models, + 0.0, + "5.5" + ), + Some(1) + ); } } diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs index 669d49dc7..c0005509c 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -94,6 +94,10 @@ pub enum AIChatHit { /// is the index into `chat.available_models` /// (`Document::select_chat_model`). SelectModel(usize), + /// Click landed inside the model-picker search/header area. + /// The picker owns keyboard input while open, so this consumes + /// the click without closing the dropdown. + FocusModelSearch, /// Click on the thinking-mode chip — host cycles /// `ChatState::thinking_mode`. CycleThinking, @@ -150,6 +154,9 @@ pub struct AIChatPlaceholder<'a> { /// Index into `state.available_models` of the picker row under /// the cursor (`Document.ui.chat_model_picker_hover`). pub model_picker_hover: Option, + /// Live model-picker search query + /// (`Document.ui.chat_model_picker_search`). + pub model_picker_search: String, /// Localised empty-state example cards — resolved at construction /// time so the grid reflows when the user flips the Globe icon. pub(crate) examples: [ExampleCard; 4], @@ -183,6 +190,7 @@ impl<'a> AIChatPlaceholder<'a> { model_picker_open: ui.chat_model_picker_open, model_picker_scroll: ui.chat_model_picker_scroll, model_picker_hover: ui.chat_model_picker_hover, + model_picker_search: ui.chat_model_picker_search.clone(), examples: example_cards(ui.locale), locale: ui.locale, } @@ -222,8 +230,10 @@ impl<'a> AIChatPlaceholder<'a> { /// height is capped at `MODEL_PICKER_MAX_H`; a taller catalog /// scrolls inside the card rather than overflowing the screen. fn model_picker_rect(&self, rect: Rect, input_rect: Rect) -> Rect { - let height = - crate::widgets::ai_chat_model_picker::picker_view_height(&self.state.available_models); + let height = crate::widgets::ai_chat_model_picker::picker_view_height( + &self.state.available_models, + &self.model_picker_search, + ); let toolbar_top = input_rect.origin.y + INPUT_AREA_HEIGHT + self.attachment_row_h(); let bottom = toolbar_top - 4.0; Rect { @@ -309,9 +319,13 @@ impl<'a> AIChatPlaceholder<'a> { point, &self.state.available_models, self.model_picker_scroll, + &self.model_picker_search, ) { return Some(AIChatHit::SelectModel(idx)); } + if rect_contains(picker, point) { + return Some(AIChatHit::FocusModelSearch); + } return Some(AIChatHit::ToggleModelPicker); } if rect_contains(input_rect, point) { @@ -747,6 +761,8 @@ impl<'a> Widget for AIChatPlaceholder<'a> { self.state.selected_model, self.model_picker_scroll, self.model_picker_hover, + &self.model_picker_search, + self.locale, ); } } diff --git a/crates/op-host-native/src/widget_host/click.rs b/crates/op-host-native/src/widget_host/click.rs index f7b5df590..2776621e7 100644 --- a/crates/op-host-native/src/widget_host/click.rs +++ b/crates/op-host-native/src/widget_host/click.rs @@ -166,6 +166,7 @@ impl WidgetHostNative { self.editor_state.chat.maximized = !self.editor_state.chat.maximized; self.editor_state.chat.collapsed = false; self.editor_state.editor_ui.chat_model_picker_open = false; + self.editor_state.editor_ui.chat_model_picker_search.clear(); self.mark_dirty(); return true; } @@ -173,6 +174,7 @@ impl WidgetHostNative { self.editor_state.chat.new_chat(); self.editor_state.editor_ui.chat_model_picker_open = false; self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; + self.editor_state.editor_ui.chat_model_picker_search.clear(); self.editor_state.editor_ui.chat_model_picker_hover = None; self.mark_dirty(); return true; @@ -184,10 +186,15 @@ impl WidgetHostNative { // a stale offset from a prior open never hides // the top of the catalog. self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; + self.editor_state.editor_ui.chat_model_picker_search.clear(); self.editor_state.editor_ui.chat_model_picker_hover = None; self.mark_dirty(); return true; } + AIChatHit::FocusModelSearch => { + self.mark_dirty(); + return true; + } AIChatHit::SelectModel(idx) => { self.editor_state.select_chat_model(idx); self.mark_dirty(); @@ -234,6 +241,7 @@ impl WidgetHostNative { let picker_was_open = self.editor_state.editor_ui.chat_model_picker_open; self.editor_state.editor_ui.chat_model_picker_open = false; self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; + self.editor_state.editor_ui.chat_model_picker_search.clear(); self.editor_state.editor_ui.chat_model_picker_hover = None; let was_focused = self.editor_state.chat.focused || picker_was_open; self.editor_state.chat.focused = false; diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index ba4e28759..b08e7cc09 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -15,6 +15,7 @@ impl WidgetHostNative { || self.editor_state.editor_ui.effect_param_focus.is_some() || self.editor_state.editor_ui.agent_settings.focus.is_some() || self.editor_state.editor_ui.icon_picker_open + || self.editor_state.editor_ui.chat_model_picker_open || self.editor_state.editor_ui.component_browser_open || self.editor_state.chat.focused || self.git_commit_focus_active() @@ -243,6 +244,7 @@ impl WidgetHostNative { Point2D::new(x, y), &self.editor_state.chat.available_models, scroll, + &self.editor_state.editor_ui.chat_model_picker_search, ); if new_hover != self.editor_state.editor_ui.chat_model_picker_hover { self.editor_state.editor_ui.chat_model_picker_hover = new_hover; diff --git a/crates/op-host-native/src/widget_host/input_tests.rs b/crates/op-host-native/src/widget_host/input_tests.rs index fc88f1849..1d5f57290 100644 --- a/crates/op-host-native/src/widget_host/input_tests.rs +++ b/crates/op-host-native/src/widget_host/input_tests.rs @@ -791,6 +791,23 @@ fn component_browser_open_owns_keyboard_search() { assert!(!host.editor_state().editor_ui.component_browser_open); } +#[test] +fn chat_model_picker_open_owns_keyboard_search() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.chat_model_picker_open = true; + host.editor_state_mut().chat.focused = true; + + assert!(host.input_active_pub()); + assert!(host.apply_text('g')); + assert!(host.apply_text('p')); + assert_eq!(host.editor_state().editor_ui.chat_model_picker_search, "gp"); + assert!(host.editor_state().chat.input.is_empty()); + assert!(host.apply_backspace()); + assert_eq!(host.editor_state().editor_ui.chat_model_picker_search, "g"); + assert!(host.apply_escape()); + assert!(!host.editor_state().editor_ui.chat_model_picker_open); +} + #[test] fn shape_picker_icon_row_opens_icon_picker() { let mut host = WidgetHostNative::new(); diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index e445f117e..2db1c1f36 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -150,6 +150,13 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + if self.editor_state.editor_ui.chat_model_picker_open && !c.is_control() { + self.editor_state.editor_ui.chat_model_picker_search.push(c); + self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; + self.editor_state.editor_ui.chat_model_picker_hover = None; + self.mark_dirty(); + return true; + } if self.editor_state.editor_ui.component_browser_open && !c.is_control() { self.editor_state.editor_ui.component_browser_search.push(c); self.mark_dirty(); @@ -269,6 +276,21 @@ impl WidgetHostNative { } return false; } + if self.editor_state.editor_ui.chat_model_picker_open { + if self + .editor_state + .editor_ui + .chat_model_picker_search + .pop() + .is_some() + { + self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; + self.editor_state.editor_ui.chat_model_picker_hover = None; + self.mark_dirty(); + return true; + } + return false; + } if self.editor_state.editor_ui.component_browser_open { if self .editor_state @@ -697,6 +719,14 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + if self.editor_state.editor_ui.chat_model_picker_open { + self.editor_state.editor_ui.chat_model_picker_open = false; + self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; + self.editor_state.editor_ui.chat_model_picker_search.clear(); + self.editor_state.editor_ui.chat_model_picker_hover = None; + self.mark_dirty(); + return true; + } if self.editor_state.editor_ui.component_browser_open { self.editor_state.editor_ui.component_browser_open = false; self.mark_dirty(); diff --git a/crates/op-host-native/src/widget_host/scroll.rs b/crates/op-host-native/src/widget_host/scroll.rs index fc1c83962..7c6e54236 100644 --- a/crates/op-host-native/src/widget_host/scroll.rs +++ b/crates/op-host-native/src/widget_host/scroll.rs @@ -108,7 +108,10 @@ impl WidgetHostNative { }); if let Some(picker) = picker { if rect_contains(picker, Point2D::new(x, y)) { - let max = max_picker_scroll(&self.editor_state.chat.available_models); + let max = max_picker_scroll( + &self.editor_state.chat.available_models, + &self.editor_state.editor_ui.chat_model_picker_search, + ); let next = (self.editor_state.editor_ui.chat_model_picker_scroll - delta_y) .clamp(0.0, max); self.editor_state.editor_ui.chat_model_picker_scroll = next; @@ -226,7 +229,10 @@ impl WidgetHostNative { }); if let Some(picker) = picker { if rect_contains(picker, Point2D::new(x, y)) { - let max = max_picker_scroll(&self.editor_state.chat.available_models); + let max = max_picker_scroll( + &self.editor_state.chat.available_models, + &self.editor_state.editor_ui.chat_model_picker_search, + ); let next = (self.editor_state.editor_ui.chat_model_picker_scroll - dy).clamp(0.0, max); self.editor_state.editor_ui.chat_model_picker_scroll = next;