fix(ai): add searchable model picker

This commit is contained in:
Fini 2026-05-30 01:10:37 +08:00
parent dec0c94710
commit 3cb62317d7
9 changed files with 433 additions and 64 deletions

View file

@ -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<usize>,
@ -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,

View file

@ -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;
}

View file

@ -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<usize> {
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<AgentProvider> = 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<usize> = 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<AgentProvider> = 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<usize> = 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<usize> {
pub fn model_at(
rect: Rect,
point: Point2D,
models: &[ModelEntry],
scroll: f32,
search: &str,
) -> Option<usize> {
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<usize>,
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)
);
}
}

View file

@ -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<usize>,
/// 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,
);
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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();

View file

@ -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();

View file

@ -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;