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:
parent
5bdd74d624
commit
ee40986d97
246
crates/openpencil-shell-core/src/widgets/ai_chat_model_picker.rs
Normal file
246
crates/openpencil-shell-core/src/widgets/ai_chat_model_picker.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue