fix(ai): add searchable model picker
This commit is contained in:
parent
dec0c94710
commit
3cb62317d7
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue