diff --git a/crates/openpencil-shell-core/src/widgets/ai_chat_model_picker.rs b/crates/openpencil-shell-core/src/widgets/ai_chat_model_picker.rs new file mode 100644 index 000000000..3d89b2b67 --- /dev/null +++ b/crates/openpencil-shell-core/src/widgets/ai_chat_model_picker.rs @@ -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 = 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 = 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 { + 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); + } +} diff --git a/crates/openpencil-shell-core/src/widgets/ai_chat_panel.rs b/crates/openpencil-shell-core/src/widgets/ai_chat_panel.rs index 73c280e55..c4b29c09d 100644 --- a/crates/openpencil-shell-core/src/widgets/ai_chat_panel.rs +++ b/crates/openpencil-shell-core/src/widgets/ai_chat_panel.rs @@ -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 { diff --git a/crates/openpencil-shell-core/src/widgets/mod.rs b/crates/openpencil-shell-core/src/widgets/mod.rs index 717104c2e..cbe3a3c7e 100644 --- a/crates/openpencil-shell-core/src/widgets/mod.rs +++ b/crates/openpencil-shell-core/src/widgets/mod.rs @@ -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; diff --git a/crates/openpencil-shell-native/src/widget_host/keyboard.rs b/crates/openpencil-shell-native/src/widget_host/keyboard.rs index 09531f3dd..a7546717a 100644 --- a/crates/openpencil-shell-native/src/widget_host/keyboard.rs +++ b/crates/openpencil-shell-native/src/widget_host/keyboard.rs @@ -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); diff --git a/crates/openpencil-shell-web/src/widget_host/press.rs b/crates/openpencil-shell-web/src/widget_host/press.rs index b307aa9b4..70f18cd18 100644 --- a/crates/openpencil-shell-web/src/widget_host/press.rs +++ b/crates/openpencil-shell-web/src/widget_host/press.rs @@ -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);