feat(ai): model chip selects among connected CLI agents
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).
This commit is contained in:
parent
4ae4ce77f0
commit
22b6da60a8
|
|
@ -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<dyn ChatProvider> = 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<dyn ChatProvider> {
|
||||
match agent_idx {
|
||||
3 => Box::new(CopilotProvider::new()),
|
||||
4 => SubprocessProvider::for_cli(CliName::Gemini)
|
||||
.map(|p| Box::new(p) as Box<dyn ChatProvider>)
|
||||
.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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue