From 22b6da60a8651c2a157b4a4c005ecc55dd0a60ca Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Fri, 15 May 2026 21:39:25 +0800 Subject: [PATCH] feat(ai): model chip selects among connected CLI agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat panel's bottom-toolbar chip was a hardcoded "Default" label. It now shows the selected CLI agent and cycles through the connected ones on click. - `UiState.chat_selected_agent` — index into `AgentProvider::ALL` (Claude Code / Codex CLI / OpenCode / GitHub Copilot / Gemini). - `Document::cycle_chat_agent` — advances to the next *connected* agent (`agent_settings.connected`); walks all 5 when none are connected so the user can pre-pick before connecting. - `AIChatHit::CycleModel` — the chip's left 150 px of the input toolbar; `apply_click` routes it to `cycle_chat_agent`. - The chip label now renders `AgentProvider::name()` instead of the static "Default". - `chat_session::provider_for_agent` routes the selected agent to its `ChatProvider`: Claude Code → SDK adapter, Copilot / Gemini → subprocess transport. Codex / OpenCode (HTTP-server CLIs whose bridge isn't wired yet) fall back to Claude Code. 856 workspace tests pass (+2: cycle walks connected / wraps, cycle with nothing connected walks all five). --- crates/openpencil-desktop/src/chat_session.rs | 25 ++++++++++++-- crates/openpencil-shell-core/src/document.rs | 11 +++++++ .../src/document/mutators.rs | 18 ++++++++++ .../src/document/tests_mutators.rs | 25 ++++++++++++++ .../src/widgets/ai_chat_panel.rs | 33 +++++++++++++++---- .../src/widget_host/keyboard.rs | 4 +++ 6 files changed, 108 insertions(+), 8 deletions(-) diff --git a/crates/openpencil-desktop/src/chat_session.rs b/crates/openpencil-desktop/src/chat_session.rs index 08434fff1..785f73a0b 100644 --- a/crates/openpencil-desktop/src/chat_session.rs +++ b/crates/openpencil-desktop/src/chat_session.rs @@ -10,10 +10,14 @@ use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::thread; -use openpencil_shell_core::chat_provider::{ChatDelta, ChatProvider, ChatRequest}; +use openpencil_shell_core::chat_provider::{ + ChatDelta, ChatProvider, ChatRequest, CliName, +}; use openpencil_shell_native::WidgetHostNative; use crate::chat_claude::ClaudeCodeProvider; +use crate::chat_copilot::CopilotProvider; +use crate::chat_subprocess::SubprocessProvider; /// One in-flight chat turn. The worker thread owns the provider and /// drains `provider.send()` into the channel; [`poll`] consumes @@ -110,7 +114,7 @@ pub fn launch_if_pending( let Some(user_text) = host.document_mut().chat.pending_send.take() else { return false; }; - let provider: Box = Box::new(ClaudeCodeProvider::new()); + let provider = provider_for_agent(host.document().ui.chat_selected_agent); let req = ChatRequest { system_prompt: String::new(), user_message: user_text, @@ -120,6 +124,23 @@ pub fn launch_if_pending( true } +/// Build the `ChatProvider` for an agent index (into +/// `AgentProvider::ALL`: 0 ClaudeCode, 1 CodexCli, 2 OpenCode, +/// 3 GithubCopilot, 4 GeminiCli). Claude Code uses its dedicated +/// SDK adapter; Copilot / Gemini use the subprocess transport. +/// Codex + OpenCode are HTTP-server CLIs whose `ChatProvider` +/// bridge isn't wired yet — they fall back to Claude Code so the +/// chat still functions rather than dead-ending. +fn provider_for_agent(agent_idx: usize) -> Box { + match agent_idx { + 3 => Box::new(CopilotProvider::new()), + 4 => SubprocessProvider::for_cli(CliName::Gemini) + .map(|p| Box::new(p) as Box) + .unwrap_or_else(|| Box::new(ClaudeCodeProvider::new())), + _ => Box::new(ClaudeCodeProvider::new()), + } +} + /// Pump the in-flight turn's deltas into the trailing (assistant) /// message. Clears `current` once the turn finishes. Returns true /// when the transcript changed so the caller can dirty the redraw. diff --git a/crates/openpencil-shell-core/src/document.rs b/crates/openpencil-shell-core/src/document.rs index 79a9871c6..774083887 100644 --- a/crates/openpencil-shell-core/src/document.rs +++ b/crates/openpencil-shell-core/src/document.rs @@ -335,6 +335,15 @@ pub struct UiState { pub locale: Locale, /// TopBar Globe dropdown open. pub locale_picker_open: bool, + /// AI chat model-picker dropdown open (the "Default ▴" chip in + /// the chat panel's bottom toolbar). Lists CLI agents that are + /// connected per `agent_settings.connected`. + pub chat_model_picker_open: bool, + /// Index into `AgentProvider::ALL` of the agent driving the AI + /// chat. Default 0 = Claude Code. The picker only *selects* + /// among connected agents, but the stored index is absolute so + /// it survives connect/disconnect churn. + pub chat_selected_agent: usize, /// File-menu dropdown anchored under folder+chevron. pub file_menu_open: bool, /// Row currently hovered while the file menu is open — drives @@ -578,6 +587,8 @@ impl Default for UiState { theme_mode: ThemeMode::Dark, locale: Locale::ZhCn, locale_picker_open: false, + chat_model_picker_open: false, + chat_selected_agent: 0, file_menu_open: false, file_menu_hover: None, locale_picker_hover: None, diff --git a/crates/openpencil-shell-core/src/document/mutators.rs b/crates/openpencil-shell-core/src/document/mutators.rs index 006124e40..aaa1e339c 100644 --- a/crates/openpencil-shell-core/src/document/mutators.rs +++ b/crates/openpencil-shell-core/src/document/mutators.rs @@ -206,6 +206,24 @@ impl Document { self.ui.align_toolbar_hover = None; } + /// Advance the AI-chat agent selection to the next *connected* + /// CLI (`agent_settings.connected`, indexed like + /// `AgentProvider::ALL`). When no agent is connected the cycle + /// still walks all 5 entries so the user can pre-pick one and + /// connect it afterward in the settings modal. Wraps. + pub fn cycle_chat_agent(&mut self) { + let connected = self.ui.agent_settings.connected; + let any_connected = connected.iter().any(|&c| c); + let start = self.ui.chat_selected_agent; + for step in 1..=connected.len() { + let idx = (start + step) % connected.len(); + if !any_connected || connected[idx] { + self.ui.chat_selected_agent = idx; + return; + } + } + } + /// Whether `id` resolves to a node that can be mutated via /// selection-aware helpers (`translate_selected`, /// `set_selected_bounds`, etc.). Hidden + locked nodes are diff --git a/crates/openpencil-shell-core/src/document/tests_mutators.rs b/crates/openpencil-shell-core/src/document/tests_mutators.rs index 050725602..fe6341d66 100644 --- a/crates/openpencil-shell-core/src/document/tests_mutators.rs +++ b/crates/openpencil-shell-core/src/document/tests_mutators.rs @@ -788,3 +788,28 @@ fn cut_selected_rolls_back_clipboard_when_delete_rejects() { "sentinel survives failed cut" ); } + +#[test] +fn cycle_chat_agent_walks_connected_then_wraps() { + let mut doc = Document::sample(); + // Connect Claude Code (0) + GitHub Copilot (3) only. + doc.ui.agent_settings.connected = [true, false, false, true, false]; + doc.ui.chat_selected_agent = 0; + // 0 → next connected is 3. + doc.cycle_chat_agent(); + assert_eq!(doc.ui.chat_selected_agent, 3); + // 3 → wraps back to 0. + doc.cycle_chat_agent(); + assert_eq!(doc.ui.chat_selected_agent, 0); +} + +#[test] +fn cycle_chat_agent_with_nothing_connected_walks_all_five() { + let mut doc = Document::sample(); + doc.ui.agent_settings.connected = [false; 5]; + doc.ui.chat_selected_agent = 0; + for expected in [1usize, 2, 3, 4, 0] { + doc.cycle_chat_agent(); + assert_eq!(doc.ui.chat_selected_agent, expected); + } +} 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 e285df54c..73c280e55 100644 --- a/crates/openpencil-shell-core/src/widgets/ai_chat_panel.rs +++ b/crates/openpencil-shell-core/src/widgets/ai_chat_panel.rs @@ -34,6 +34,9 @@ const INPUT_AREA_HEIGHT: f32 = 56.0; /// Toolbar below the textarea — model picker on left, attach + /// send on right. Mirrors the TS panel's bottom row. const INPUT_TOOLBAR_HEIGHT: f32 = 40.0; +/// Click-width of the bottom-toolbar model chip (sparkles + agent +/// name + chevron). Fixed so hit-test needs no text measurement. +const MODEL_CHIP_W: f32 = 150.0; /// Total reserved space for the input + toolbar block. const INPUT_HEIGHT: f32 = INPUT_AREA_HEIGHT + INPUT_TOOLBAR_HEIGHT; @@ -83,6 +86,10 @@ pub enum AIChatHit { /// Click on the chevron at the top-left of the header — host /// 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, } pub struct AIChatPlaceholder<'a> { @@ -104,9 +111,11 @@ pub struct AIChatPlaceholder<'a> { /// the empty-state body, between the example cards and the /// separator above the input. pub label_tip_select_elements: String, - /// Localised "Default" model label — appears in the bottom - /// toolbar's model picker. - pub label_default_model: 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, } impl<'a> AIChatPlaceholder<'a> { @@ -126,7 +135,10 @@ 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(), - label_default_model: "Default".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()), } } @@ -158,6 +170,15 @@ impl<'a> AIChatPlaceholder<'a> { size: Point2D::new(rect.size.x - PAD * 2.0, INPUT_HEIGHT), }; 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). + 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); + } // Send chip is the rightmost ~40px of the input area. let send_x = input_rect.origin.x + input_rect.size.x - 40.0; if point.x >= send_x { @@ -381,7 +402,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> { ); model_x += 20.0; let model_label = TextLayout::single_run( - &self.label_default_model, + &self.model_label, "system-ui", 12.0, to_jian_color(self.theme.muted_foreground), @@ -389,7 +410,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> { ); cx.backend .draw_text(&model_label, Point2D::new(model_x, toolbar_center_y + 4.0)); - let model_w = cx.backend.measure_text(&self.label_default_model, 12.0); + let model_w = cx.backend.measure_text(&self.model_label, 12.0); model_x += model_w + 4.0; draw_icon( cx.backend, diff --git a/crates/openpencil-shell-native/src/widget_host/keyboard.rs b/crates/openpencil-shell-native/src/widget_host/keyboard.rs index 22287d86b..09531f3dd 100644 --- a/crates/openpencil-shell-native/src/widget_host/keyboard.rs +++ b/crates/openpencil-shell-native/src/widget_host/keyboard.rs @@ -413,6 +413,10 @@ impl WidgetHostNative { self.document.chat.collapsed = !self.document.chat.collapsed; return true; } + AIChatHit::CycleModel => { + self.document.cycle_chat_agent(); + return true; + } } } }