feat(ai): model-picker dropdown in the chat panel

Replace the chat panel's agent-cycling chip with a real model
picker: the chip shows the selected model's provider brand logo +
display name, and clicking it opens an upward dropdown listing
chat.available_models grouped by provider with a selected check
(mirrors the TS ai-chat-model-selector). Selecting a model also
re-syncs chat_selected_agent to its provider so the chat transport
targets the matching CLI. Outside clicks dismiss the picker.
This commit is contained in:
Kayshen-X 2026-05-15 23:34:08 +08:00
parent 5bdd74d624
commit ee40986d97
5 changed files with 364 additions and 31 deletions

View file

@ -0,0 +1,246 @@
//! 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.
use crate::agent_settings_state::AgentProvider;
use crate::chat_models::ModelEntry;
use crate::theme::Theme;
use crate::widgets::brand_icons::{paint_brand_logo, paint_opencode_logo, BrandLogo};
use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::property_panel_inputs::to_jian_color;
use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect, TextLayout};
/// Height of a provider group-header row.
pub const MODEL_GROUP_H: f32 = 22.0;
/// Height of a single model row.
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;
/// One laid-out row in the dropdown.
enum Row {
/// Provider group header — carries the provider for its logo.
Header(AgentProvider),
/// Selectable model — carries its index into the flat list.
Model(usize),
}
/// 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)) {
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);
y += MODEL_GROUP_H;
last_provider = Some(entry.provider);
}
f(&Row::Model(idx), y, MODEL_ROW_H);
y += MODEL_ROW_H;
}
}
/// 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);
}
}
groups as f32 * MODEL_GROUP_H
+ models.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.
pub fn model_at(rect: Rect, point: Point2D, models: &[ModelEntry]) -> Option<usize> {
if point.x < rect.origin.x
|| point.x > rect.origin.x + rect.size.x
|| point.y < rect.origin.y
|| point.y > rect.origin.y + rect.size.y
{
return None;
}
let mut hit = None;
walk_rows(models, rect.origin.y, |row, y, h| {
if let Row::Model(idx) = row {
if point.y >= y && point.y < y + h {
hit = Some(*idx);
}
}
});
hit
}
/// Paint the dropdown card + grouped rows. `selected` is the index
/// of the active model (gets a check mark). `rect` is the full
/// dropdown bounds as positioned by the caller.
pub fn paint_model_picker(
cx: &mut PaintCx<'_>,
theme: &Theme,
rect: Rect,
models: &[ModelEntry],
selected: usize,
) {
// Card background + border.
cx.backend.fill_round_rect(rect, 10.0, theme.popover);
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;
walk_rows(models, rect.origin.y, |row, y, h| match row {
Row::Header(provider) => {
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,
);
let label = TextLayout::single_run(
provider_label(*provider),
"system-ui",
10.0,
to_jian_color(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&label, Point2D::new(row_left + 18.0, y + h / 2.0 + 3.0));
}
Row::Model(idx) => {
let is_selected = *idx == selected;
if is_selected {
cx.backend.fill_round_rect(
Rect {
origin: Point2D::new(rect.origin.x + 4.0, y + 1.0),
size: Point2D::new(rect.size.x - 8.0, h - 2.0),
},
6.0,
theme.muted,
);
draw_icon(
cx.backend,
Icon::Check,
Point2D::new(row_left, y + (h - 13.0) / 2.0),
13.0,
theme.foreground,
1.6,
);
}
let color = if is_selected {
theme.foreground
} else {
theme.muted_foreground
};
let name = models
.get(*idx)
.map(|m| m.display_name.as_str())
.unwrap_or("");
let label = TextLayout::single_run(
name,
"system-ui",
12.0,
to_jian_color(color),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&label,
Point2D::new(row_left + 22.0, y + h / 2.0 + 4.0),
);
}
});
let _ = row_w;
}
/// 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`.
pub fn paint_provider_logo(
cx: &mut PaintCx<'_>,
provider: AgentProvider,
top_left: Point2D,
size: f32,
color: Color,
) {
match provider {
AgentProvider::ClaudeCode => {
paint_brand_logo(cx.backend, BrandLogo::Claude, top_left, size, color)
}
AgentProvider::CodexCli => {
paint_brand_logo(cx.backend, BrandLogo::OpenAI, top_left, size, color)
}
AgentProvider::GeminiCli => {
paint_brand_logo(cx.backend, BrandLogo::Gemini, top_left, size, color)
}
AgentProvider::GithubCopilot => {
paint_brand_logo(cx.backend, BrandLogo::Copilot, top_left, size, color)
}
AgentProvider::OpenCode => paint_opencode_logo(cx.backend, top_left, size, color),
}
}
/// Uppercase provider name for the group header (matches the TS
/// dropdown's `providerName` styling).
fn provider_label(provider: AgentProvider) -> &'static str {
match provider {
AgentProvider::ClaudeCode => "ANTHROPIC",
AgentProvider::CodexCli => "OPENAI",
AgentProvider::GeminiCli => "GEMINI",
AgentProvider::GithubCopilot => "GITHUB COPILOT",
AgentProvider::OpenCode => "OPENCODE",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(p: AgentProvider, v: &str) -> ModelEntry {
ModelEntry::new(p, v, v)
}
#[test]
fn content_height_counts_groups_and_rows() {
let models = vec![
entry(AgentProvider::ClaudeCode, "a"),
entry(AgentProvider::ClaudeCode, "b"),
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);
}
#[test]
fn model_at_resolves_row_and_skips_headers() {
let models = vec![
entry(AgentProvider::ClaudeCode, "a"),
entry(AgentProvider::CodexCli, "b"),
];
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
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;
assert_eq!(
model_at(rect, Point2D::new(100.0, first_row_y), &models),
Some(0)
);
// A click on the header band resolves to nothing.
let header_y = MODEL_PICKER_PAD_Y + MODEL_GROUP_H / 2.0;
assert_eq!(model_at(rect, Point2D::new(100.0, header_y), &models), None);
}
}

View file

@ -87,9 +87,13 @@ pub enum AIChatHit {
/// flips the `ChatState::collapsed` flag.
ToggleCollapse,
/// Click on the model chip (bottom-left of the input toolbar) —
/// host advances `chat_selected_agent` to the next connected
/// CLI agent (`Document::cycle_chat_agent`).
CycleModel,
/// host toggles `ui.chat_model_picker_open` to open / close the
/// model dropdown.
ToggleModelPicker,
/// Click on a model row in the open picker dropdown — payload
/// is the index into `chat.available_models`
/// (`Document::select_chat_model`).
SelectModel(usize),
}
pub struct AIChatPlaceholder<'a> {
@ -111,11 +115,14 @@ pub struct AIChatPlaceholder<'a> {
/// the empty-state body, between the example cards and the
/// separator above the input.
pub label_tip_select_elements: String,
/// Name of the AI-chat agent shown in the bottom toolbar's
/// model chip — the connected CLI selected via `chat_selected_agent`
/// (`AgentProvider::label`). Falls back to "Default" only when
/// the stored index is somehow out of range.
pub model_label: String,
/// Chip label shown when no model is selected / discovered yet
/// (`ai.noModelsConnected`).
pub label_no_models: String,
/// Whether the model-picker dropdown is open
/// (`Document.ui.chat_model_picker_open`). The picker lists
/// `state.available_models`; the active row is
/// `state.selected_model`.
pub model_picker_open: bool,
}
impl<'a> AIChatPlaceholder<'a> {
@ -135,10 +142,23 @@ impl<'a> AIChatPlaceholder<'a> {
label_start_with_ai: doc.t("ai.tryExample").to_string(),
label_input_placeholder: doc.t("ai.designWithAgent").to_string(),
label_tip_select_elements: doc.t("ai.tipSelectElements").to_string(),
model_label: crate::agent_settings_state::AgentProvider::ALL
.get(doc.ui.chat_selected_agent)
.map(|a| a.name().to_string())
.unwrap_or_else(|| "Default".to_string()),
label_no_models: doc.t("ai.noModelsConnected").to_string(),
model_picker_open: doc.ui.chat_model_picker_open,
}
}
/// Bounds of the model-picker dropdown — anchored just above
/// the bottom toolbar (the chip), growing upward over the
/// message list. `input_rect` is the panel's input box.
fn model_picker_rect(&self, rect: Rect, input_rect: Rect) -> Rect {
let height = crate::widgets::ai_chat_model_picker::picker_content_height(
&self.state.available_models,
);
let toolbar_top = input_rect.origin.y + INPUT_AREA_HEIGHT;
let bottom = toolbar_top - 4.0;
Rect {
origin: Point2D::new(rect.origin.x + PAD, bottom - height),
size: Point2D::new(rect.size.x - PAD * 2.0, height),
}
}
@ -169,15 +189,30 @@ impl<'a> AIChatPlaceholder<'a> {
),
size: Point2D::new(rect.size.x - PAD * 2.0, INPUT_HEIGHT),
};
// Model-picker dropdown — an overlay above the chip. When
// open it behaves modally: a row click selects, any other
// click dismisses it. Hit-tested before the input so a row
// click isn't eaten by the message list beneath.
if self.model_picker_open {
let picker = self.model_picker_rect(rect, input_rect);
if let Some(idx) = crate::widgets::ai_chat_model_picker::model_at(
picker,
point,
&self.state.available_models,
) {
return Some(AIChatHit::SelectModel(idx));
}
return Some(AIChatHit::ToggleModelPicker);
}
if rect_contains(input_rect, point) {
// Bottom toolbar strip = the lower `INPUT_TOOLBAR_HEIGHT`
// of the input box; its left `MODEL_CHIP_W` is the model
// chip (advances the connected-CLI selection on click).
// chip (opens / closes the model-picker dropdown).
let toolbar_top = input_rect.origin.y + INPUT_AREA_HEIGHT;
if point.y >= toolbar_top
&& point.x <= input_rect.origin.x + MODEL_CHIP_W
{
return Some(AIChatHit::CycleModel);
return Some(AIChatHit::ToggleModelPicker);
}
// Send chip is the rightmost ~40px of the input area.
let send_x = input_rect.origin.x + input_rect.size.x - 40.0;
@ -390,27 +425,43 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
// on the right (mirrors the TS panel's bottom row).
let toolbar_y = input_rect.origin.y + INPUT_AREA_HEIGHT;
let toolbar_center_y = toolbar_y + INPUT_TOOLBAR_HEIGHT / 2.0;
// Sparkles glyph + "Default" + chevron — model picker.
// Model chip — brand logo of the selected model's provider
// + its display name + a chevron. Click toggles the picker.
let mut model_x = rect.origin.x + PAD;
draw_icon(
cx.backend,
Icon::Sparkles,
Point2D::new(model_x, toolbar_center_y - 7.0),
14.0,
self.theme.muted_foreground,
1.4,
);
let selected = self.state.selected_model_entry();
let chip_color = self.theme.muted_foreground;
match selected {
Some(entry) => crate::widgets::ai_chat_model_picker::paint_provider_logo(
cx,
entry.provider,
Point2D::new(model_x, toolbar_center_y - 7.0),
14.0,
chip_color,
),
// No model discovered yet — generic sparkles glyph.
None => draw_icon(
cx.backend,
Icon::Sparkles,
Point2D::new(model_x, toolbar_center_y - 7.0),
14.0,
chip_color,
1.4,
),
}
model_x += 20.0;
let model_name: &str = selected
.map(|m| m.display_name.as_str())
.unwrap_or(self.label_no_models.as_str());
let model_label = TextLayout::single_run(
&self.model_label,
model_name,
"system-ui",
12.0,
to_jian_color(self.theme.muted_foreground),
to_jian_color(chip_color),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&model_label, Point2D::new(model_x, toolbar_center_y + 4.0));
let model_w = cx.backend.measure_text(&self.model_label, 12.0);
let model_w = cx.backend.measure_text(model_name, 12.0);
model_x += model_w + 4.0;
draw_icon(
cx.backend,
@ -465,6 +516,19 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
self.theme.muted_foreground,
1.4,
);
// Model-picker dropdown paints last so it sits above the
// message list / examples / input.
if self.model_picker_open {
let picker = self.model_picker_rect(rect, input_rect);
crate::widgets::ai_chat_model_picker::paint_model_picker(
cx,
&self.theme,
picker,
&self.state.available_models,
self.state.selected_model,
);
}
}
fn access_node(&self) -> accesskit::Node {

View file

@ -72,6 +72,7 @@ pub mod agent_settings_images;
pub mod agent_settings_mcp;
pub mod agent_settings_panel;
pub mod agent_settings_system;
pub mod ai_chat_model_picker;
pub mod ai_chat_panel;
pub mod align_toolbar;
pub mod color_picker;

View file

@ -413,15 +413,25 @@ impl WidgetHostNative {
self.document.chat.collapsed = !self.document.chat.collapsed;
return true;
}
AIChatHit::CycleModel => {
self.document.cycle_chat_agent();
AIChatHit::ToggleModelPicker => {
let ui = &mut self.document.ui;
ui.chat_model_picker_open = !ui.chat_model_picker_open;
return true;
}
AIChatHit::SelectModel(idx) => {
self.document.select_chat_model(idx);
return true;
}
}
}
}
// Click outside chat panel — defocus the input.
let was_focused = self.document.chat.focused;
// Click outside chat panel — defocus the input and close
// the model picker if it was open. Either counts as a
// visible change so the fall-through returns request a
// redraw.
let picker_was_open = self.document.ui.chat_model_picker_open;
self.document.ui.chat_model_picker_open = false;
let was_focused = self.document.chat.focused || picker_was_open;
self.document.chat.focused = false;
let (cx0, _cy0, _cw, _ch) = self.canvas_region(viewport_width, viewport_height);
let toolbar = Toolbar::for_document(&self.document);

View file

@ -407,10 +407,22 @@ impl WidgetHost {
self.document.chat.collapsed = !self.document.chat.collapsed;
return true;
}
AIChatHit::ToggleModelPicker => {
let ui = &mut self.document.ui;
ui.chat_model_picker_open = !ui.chat_model_picker_open;
return true;
}
AIChatHit::SelectModel(idx) => {
self.document.select_chat_model(idx);
return true;
}
}
}
}
let was_focused = self.document.chat.focused;
// Click outside the chat panel closes the model picker.
let picker_was_open = self.document.ui.chat_model_picker_open;
self.document.ui.chat_model_picker_open = false;
let was_focused = self.document.chat.focused || picker_was_open;
self.document.chat.focused = false;
let toolbar_rect = self.toolbar_rect(viewport_w);