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:
Kayshen-X 2026-05-15 21:39:25 +08:00
parent 4ae4ce77f0
commit 22b6da60a8
6 changed files with 108 additions and 8 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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

View file

@ -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);
}
}

View file

@ -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,

View file

@ -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;
}
}
}
}