Files
solidcraft-upstream/crates/ui-egui/src/agent_chat/settings.rs
T
bherbruckandClaude Opus 5.5 2d069ca186 In-app agent chat: your own coding agents over ACP, plus OpenAI-compatible endpoints (#1102)
* Agent chat prototype: drive an ACP agent the user already has from a side panel

A desktop-only chat panel (off by default) that starts a coding agent over the
Agent Client Protocol and gives it SolidCraft's MCP server, bridged to the open
window through the token-protected control channel with the default folder
limits. It finds installed agents (Claude Code and Codex adapters, Gemini CLI,
Copilot, Qwen, OpenCode, goose) with version and login state, streams text,
tool calls and plans, shows the agent's permission requests, and when an agent
isn't signed in shows its own login methods without handling any credential.

Design notes and findings: docs/design/in-app-agent-chat.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: say only what the end-to-end run showed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: gate, one undo per message, docking, process groups, discovery polish

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: Settings → AI, several agents with a switcher, OpenAI-compatible endpoints, permission modes, toolbar button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: translations, tool cards, Add agent dialog fixes, test agent saves on its own

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: third-party licences for the keychain crates; clippy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: badge text from the theme tokens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Agent chat: Auto-allow model edits is the default permission mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:22:38 -04:00

542 lines
23 KiB
Rust

//! Settings → AI: the master switch, the agents (found and added, each with Test, Default and,
//! for added ones, Remove), permissions (global and per agent, and the "always allow" answers),
//! and the panel. Plus the Add agent dialog: an installed ACP agent, a custom command, or an
//! OpenAI-compatible endpoint (whose key goes to the system keychain).
use egui::{RichText, Stroke};
use super::agents::{self, AgentConfig, Kind, Mode};
use super::*;
use crate::theme::Tokens;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AddKind {
#[default]
Installed,
Custom,
Api,
}
/// The Add agent dialog's fields.
#[derive(Default)]
pub struct AddState {
pub kind: AddKind,
pub name: String,
pub command: String,
pub installed: usize,
pub preset: usize,
pub base_url: String,
pub model: String,
pub key: String,
pub error: Option<String>,
test: Option<Test>,
}
/// A connection test running or done.
pub enum Test {
Running(std::sync::mpsc::Receiver<Result<String, String>>),
Done(Result<String, String>),
}
impl Test {
fn poll(&mut self) {
if let Test::Running(rx) = self
&& let Ok(r) = rx.try_recv()
{
*self = Test::Done(r);
}
}
}
/// Settings state that isn't preferences: the dialog and the tests.
#[derive(Default)]
pub struct State {
pub add: Option<AddState>,
pub tests: std::collections::HashMap<String, Test>,
}
/// Open the Add agent dialog.
pub fn open_add(app: &mut SolveApp) {
let preset = agents::API_PRESETS.first();
app.agent_chat.settings.add = Some(AddState {
base_url: preset.map(|p| p.1.to_string()).unwrap_or_default(),
name: preset.map(|p| p.0.to_string()).unwrap_or_default(),
..Default::default()
});
}
/// Run a connection test in the background (it starts the agent briefly, or asks the endpoint
/// for its models).
#[cfg(not(target_arch = "wasm32"))]
fn start_test(app: &SolveApp, e: &agents::Entry, key: Option<String>) -> Test {
let (tx, rx) = std::sync::mpsc::channel();
match e.kind {
Kind::Acp => {
let argv = e.argv.clone();
std::thread::spawn(move || {
let _ = tx.send(super::acp_client::test_agent(&argv, &working_dir()));
});
}
Kind::Api => {
let Some(http) = app.services.agent_http.clone() else { return Test::Done(Err(tr("ui.ai_no_http").to_string())) };
let (url, model) = (e.base_url.clone(), e.model.clone());
std::thread::spawn(move || {
let _ = tx.send(super::api_client::test_endpoint(&http, &url, key.as_deref(), &model));
});
}
}
Test::Running(rx)
}
fn test_line(ui: &mut egui::Ui, t: &Option<&Test>) {
let tk = Tokens::get();
match t {
Some(Test::Running(_)) => {
ui.label(RichText::new(tr("ui.ai_testing")).color(tk.text_dim).size(11.0));
}
Some(Test::Done(Ok(m))) => {
ui.label(RichText::new(m.clone()).color(tk.accent).size(11.0));
}
Some(Test::Done(Err(e))) => {
ui.add(egui::Label::new(RichText::new(e).color(tk.error).size(11.0)).wrap());
}
None => {}
}
}
fn section(ui: &mut egui::Ui, title: &str) {
ui.add_space(10.0);
ui.label(RichText::new(title).strong().size(13.0));
ui.separator();
}
fn note(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get();
ui.add(egui::Label::new(RichText::new(text).size(11.0).color(t.text_dim)).wrap());
}
/// The AI page of Settings.
pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let r = ui.checkbox(&mut app.preferences.ai_features, RichText::new(tr("ui.ai_show_features")).strong());
crate::scenario::publish_handle("prefs:ai_features", r.rect.center());
note(ui, tr("ui.ai_show_features_note"));
if cfg!(target_arch = "wasm32") {
note(ui, tr("ui.agent_chat_desktop_only"));
return;
}
if !app.preferences.ai_features {
return;
}
ui.add_enabled_ui(true, |ui| {
let r = ui.checkbox(&mut app.preferences.ai_toolbar_button, tr("ui.ai_toolbar_button"));
crate::scenario::publish_handle("prefs:ai_toolbar_button", r.rect.center());
});
note(ui, tr("ui.ai_toolbar_button_note"));
#[cfg(not(target_arch = "wasm32"))]
{
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, &ui.ctx().clone());
}
agents_section(app, ui);
}
section(ui, tr("ui.ai_permissions"));
for m in Mode::ALL {
let r = ui.radio_value(&mut app.preferences.ai_permissions, m, m.label());
crate::scenario::publish_handle(&format!("prefs:ai_mode:{}", m.id()), r.rect.center());
note(
ui,
tr(match m {
Mode::Ask => "ui.ai_mode_ask_note",
Mode::Edits => "ui.ai_mode_edits_note",
Mode::All => "ui.ai_mode_all_note",
}),
);
}
if app.preferences.ai_permissions == Mode::All {
egui::Frame::NONE.stroke(Stroke::new(1.0, t.warning)).corner_radius(4.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.add(egui::Label::new(RichText::new(tr("ui.ai_mode_all_warning")).color(t.warning)).wrap());
});
}
note(ui, tr("ui.ai_modes_scope"));
// "Always allow" answers.
let remembered: Vec<(String, Vec<String>)> =
app.preferences.agent_always_allow.iter().filter(|(_, v)| !v.is_empty()).map(|(k, v)| (k.clone(), v.clone())).collect();
ui.add_space(4.0);
ui.label(RichText::new(tr("ui.ai_always_allowed")).strong());
if remembered.is_empty() {
note(ui, tr("ui.ai_always_allowed_none"));
}
let names: std::collections::HashMap<String, String> = agents::entries(app).into_iter().map(|e| (e.id, e.name)).collect();
for (agent, tools) in remembered {
ui.horizontal_wrapped(|ui| {
ui.label(names.get(&agent).cloned().unwrap_or_else(|| agent.clone()));
for tool in &tools {
ui.label(RichText::new(tool).monospace().size(11.0).color(t.text_dim));
}
if ui.small_button(tr("ui.ai_reset")).clicked() {
app.preferences.agent_always_allow.remove(&agent);
}
});
}
section(ui, tr("ui.ai_panel"));
egui::Grid::new("sc_ai_panel_grid").num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| {
ui.label(tr("ui.agent_chat_side"));
ui.horizontal(|ui| {
let side = &mut app.preferences.agent_chat_side;
if ui.radio(side == "left", tr("ui.agent_chat_left")).clicked() {
*side = "left".into();
}
if ui.radio(side != "left", tr("ui.agent_chat_right")).clicked() {
*side = "right".into();
}
});
ui.end_row();
ui.label(tr("ui.agent_chat_turn_limit"));
ui.add(egui::DragValue::new(&mut app.preferences.agent_chat_turn_limit).range(1..=200));
ui.end_row();
});
ui.add_space(6.0);
note(ui, tr("ui.agent_chat_privacy_note"));
}
/// The agents: found and added, with Test, Default, a permission override and Remove.
#[cfg(not(target_arch = "wasm32"))]
fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
section(ui, tr("ui.ai_agents"));
for test in app.agent_chat.settings.tests.values_mut() {
test.poll();
}
let all = agents::entries(app);
let d = &app.preferences.agent_default;
let default = if all.iter().any(|e| &e.id == d) { Some(d.clone()) } else { all.first().map(|e| e.id.clone()) };
if app.agent_chat.detecting.is_some() {
ui.label(RichText::new(tr("ui.agent_chat_detecting")).color(t.text_dim));
} else if all.is_empty() {
note(ui, tr("ui.agent_chat_none_found"));
}
let mut remove: Option<String> = None;
let mut test: Option<agents::Entry> = None;
for e in &all {
egui::Frame::NONE.stroke(Stroke::new(1.0, t.border)).corner_radius(5.0).inner_margin(egui::Margin::symmetric(8, 5)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
super::panel::kind_icon(ui, e.kind);
ui.label(RichText::new(&e.name).strong());
ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0));
super::panel::login_chip(ui, e.login);
if !e.found {
ui.label(RichText::new(tr("ui.ai_added")).color(t.text_dim).size(11.0));
}
});
ui.horizontal(|ui| {
let is_default = default.as_deref() == Some(e.id.as_str());
if ui.radio(is_default, tr("ui.ai_default")).clicked() {
app.preferences.agent_default = e.id.clone();
}
let b = ui.small_button(tr("ui.ai_test"));
crate::scenario::publish_handle(&format!("prefs:ai_test:{}", e.id), b.rect.center());
if b.on_hover_text(tr(if e.kind == Kind::Acp { "ui.ai_test_acp_tip" } else { "ui.ai_test_api_tip" })).clicked() {
test = Some(e.clone());
}
// Its own permission mode, or the global one.
let cur = app.preferences.agent_permissions.get(&e.id).copied();
let shown = cur.map_or_else(|| tr("ui.ai_mode_global").to_string(), |m| m.label().to_string());
let mut pick: Option<Option<Mode>> = None;
egui::ComboBox::from_id_salt(("sc_ai_mode", &e.id)).width(150.0).selected_text(shown).show_ui(ui, |ui| {
if ui.selectable_label(cur.is_none(), tr("ui.ai_mode_global")).clicked() {
pick = Some(None);
}
for m in Mode::ALL {
if ui.selectable_label(cur == Some(m), m.label()).clicked() {
pick = Some(Some(m));
}
}
});
match pick {
Some(Some(m)) => {
app.preferences.agent_permissions.insert(e.id.clone(), m);
}
Some(None) => {
app.preferences.agent_permissions.remove(&e.id);
}
None => {}
}
if !e.found && ui.small_button(tr("ui.ai_remove")).clicked() {
remove = Some(e.id.clone());
}
});
test_line(ui, &app.agent_chat.settings.tests.get(&e.id));
});
ui.add_space(3.0);
}
ui.horizontal(|ui| {
let b = ui.button(tr("ui.ai_add_agent"));
crate::scenario::publish_handle("prefs:ai_add", b.rect.center());
if b.clicked() {
open_add(app);
}
if ui.add_enabled(app.agent_chat.detecting.is_none(), egui::Button::new(tr("ui.agent_chat_refresh"))).clicked() {
refresh(app, &ui.ctx().clone());
}
});
if let Some(e) = test {
let key = if e.key { app.services.secrets.as_ref().and_then(|s| s.get(&e.id).ok().flatten()) } else { None };
let started = start_test(app, &e, key);
app.agent_chat.settings.tests.insert(e.id.clone(), started);
}
if let Some(id) = remove {
app.preferences.agents.retain(|a| a.id != id);
app.preferences.agent_permissions.remove(&id);
app.preferences.agent_always_allow.remove(&id);
if let Some(s) = &app.services.secrets {
let _ = s.delete(&id);
}
if app.agent_chat.agent_id.as_deref() == Some(id.as_str()) {
app.agent_chat.agent_id = None;
}
}
if app.agent_chat.settings.tests.values().any(|t| matches!(t, Test::Running(_))) {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(200));
}
}
/// The Add agent dialog (drawn every frame while it is open).
pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) {
if app.agent_chat.settings.add.is_none() {
return;
}
#[cfg(not(target_arch = "wasm32"))]
let found: Vec<agents::Entry> = agents::entries(app).into_iter().filter(|e| e.found).collect();
#[cfg(target_arch = "wasm32")]
let found: Vec<agents::Entry> = Vec::new();
let has_keychain = app.services.secrets.is_some();
let mut open = true;
let mut add = false;
let mut test = false;
let t = Tokens::get();
egui::Window::new(tr("ui.ai_add_agent_title"))
.id(egui::Id::new("sc_ai_add"))
.collapsible(false)
.resizable(false)
.default_width(460.0)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.open(&mut open)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let Some(s) = app.agent_chat.settings.add.as_mut() else { return };
if let Some(test) = s.test.as_mut() {
test.poll();
}
ui.horizontal(|ui| {
for (k, label) in
[(AddKind::Installed, "ui.ai_add_installed"), (AddKind::Custom, "ui.ai_add_custom"), (AddKind::Api, "ui.ai_add_api")]
{
let r = ui.selectable_value(&mut s.kind, k, tr(label));
crate::scenario::publish_handle(&format!("ai_add:{label}"), r.rect.center());
if r.clicked() {
s.error = None;
s.test = None;
s.command.clear();
s.name.clear();
if k == AddKind::Api
&& let Some((name, url, _)) = agents::API_PRESETS.get(s.preset)
{
s.name = name.to_string();
s.base_url = url.to_string();
}
}
}
});
ui.separator();
egui::Grid::new("sc_ai_add_grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| match s.kind {
AddKind::Installed => {
ui.label(tr("ui.agent_chat_pick"));
if found.is_empty() {
ui.label(RichText::new(tr("ui.agent_chat_none_found_short")).color(t.text_dim));
} else {
let sel = found.get(s.installed).map(|e| e.name.clone()).unwrap_or_default();
egui::ComboBox::from_id_salt("sc_ai_add_installed").width(260.0).selected_text(sel).show_ui(ui, |ui| {
for (i, e) in found.iter().enumerate() {
if ui.selectable_value(&mut s.installed, i, format!("{} {}", e.name, e.detail())).clicked() {
s.command = e.argv.iter().map(|a| super::panel::quote(a)).collect::<Vec<_>>().join(" ");
s.name = format!("{} ({})", e.name, tr("ui.ai_copy_suffix"));
}
}
});
}
ui.end_row();
if s.command.is_empty()
&& let Some(e) = found.get(s.installed)
{
s.command = e.argv.iter().map(|a| super::panel::quote(a)).collect::<Vec<_>>().join(" ");
s.name = format!("{} ({})", e.name, tr("ui.ai_copy_suffix"));
}
ui.label(tr("ui.ai_name"));
ui.add(egui::TextEdit::singleline(&mut s.name).desired_width(300.0));
ui.end_row();
ui.label(tr("ui.agent_chat_command"));
ui.add(egui::TextEdit::singleline(&mut s.command).desired_width(300.0));
ui.end_row();
ui.label("");
ui.add(egui::Label::new(RichText::new(tr("ui.ai_add_installed_note")).size(11.0).color(t.text_dim)).wrap());
ui.end_row();
}
AddKind::Custom => {
ui.label(tr("ui.ai_name"));
ui.add(egui::TextEdit::singleline(&mut s.name).hint_text("My agent").desired_width(300.0));
ui.end_row();
ui.label(tr("ui.agent_chat_command"));
ui.add(egui::TextEdit::singleline(&mut s.command).hint_text("my-agent --acp").desired_width(300.0));
ui.end_row();
ui.label("");
ui.add(egui::Label::new(RichText::new(tr("ui.agent_chat_custom_hint")).size(11.0).color(t.text_dim)).wrap());
ui.end_row();
}
AddKind::Api => {
ui.label(tr("ui.ai_preset"));
let presets: Vec<String> =
agents::API_PRESETS.iter().map(|p| p.0.to_string()).chain(std::iter::once(tr("ui.ai_custom_url").to_string())).collect();
let sel = presets.get(s.preset).cloned().unwrap_or_default();
egui::ComboBox::from_id_salt("sc_ai_add_preset").width(200.0).selected_text(sel).show_ui(ui, |ui| {
for (i, p) in presets.iter().enumerate() {
if ui.selectable_value(&mut s.preset, i, p).clicked() {
if let Some((name, url, _)) = agents::API_PRESETS.get(i) {
s.base_url = url.to_string();
s.name = name.to_string();
} else {
s.base_url.clear();
}
}
}
});
ui.end_row();
ui.label(tr("ui.ai_name"));
ui.add(egui::TextEdit::singleline(&mut s.name).desired_width(300.0));
ui.end_row();
ui.label(tr("ui.ai_base_url"));
ui.add(egui::TextEdit::singleline(&mut s.base_url).hint_text("https://example.com/v1").desired_width(300.0));
ui.end_row();
ui.label(tr("ui.ai_model"));
ui.add(egui::TextEdit::singleline(&mut s.model).hint_text("qwen3:14b").desired_width(300.0));
ui.end_row();
ui.label(tr("ui.ai_key"));
ui.add_enabled(
has_keychain,
egui::TextEdit::singleline(&mut s.key).password(true).hint_text(tr("ui.ai_key_hint")).desired_width(300.0),
);
ui.end_row();
ui.label("");
let note = if has_keychain { tr("ui.ai_key_note") } else { tr("ui.ai_no_keychain") };
ui.add(egui::Label::new(RichText::new(note).size(11.0).color(t.text_dim)).wrap());
ui.end_row();
ui.label("");
ui.horizontal(|ui| {
if ui.button(tr("ui.ai_test")).clicked() {
test = true;
}
test_line(ui, &s.test.as_ref());
});
ui.end_row();
}
});
if let Some(e) = &s.error {
ui.label(RichText::new(e).color(t.error));
}
ui.separator();
ui.horizontal(|ui| {
let b = ui.add(egui::Button::new(tr("ui.ai_add")).fill(t.accent_soft));
crate::scenario::publish_handle("ai_add:add", b.rect.center());
if b.clicked() {
add = true;
}
});
});
if test {
#[cfg(not(target_arch = "wasm32"))]
if let Some(s) = app.agent_chat.settings.add.as_ref() {
let url = agents::normalize_url(&s.base_url);
let e = agents::Entry {
id: String::new(),
name: s.name.clone(),
kind: Kind::Api,
argv: Vec::new(),
version: None,
login: "",
found: false,
base_url: url.clone().unwrap_or_default(),
model: s.model.trim().to_string(),
key: false,
};
let key = Some(s.key.trim().to_string()).filter(|k| !k.is_empty());
let result = match url {
Ok(_) => start_test(app, &e, key),
Err(err) => Test::Done(Err(err)),
};
if let Some(s) = app.agent_chat.settings.add.as_mut() {
s.test = Some(result);
}
}
}
if add {
match add_agent(app) {
Ok(()) => open = false,
Err(e) => {
if let Some(s) = app.agent_chat.settings.add.as_mut() {
s.error = Some(e);
}
}
}
}
if !open {
app.agent_chat.settings.add = None;
}
if app.agent_chat.settings.add.as_ref().is_some_and(|s| matches!(s.test, Some(Test::Running(_)))) {
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
}
/// Add what the dialog holds; its key goes to the keychain.
fn add_agent(app: &mut SolveApp) -> Result<(), String> {
let Some(s) = app.agent_chat.settings.add.as_ref() else { return Ok(()) };
let name = s.name.trim().chars().take(80).collect::<String>();
let mut c = AgentConfig { name, ..Default::default() };
let mut key: Option<String> = None;
match s.kind {
AddKind::Installed | AddKind::Custom => {
let cmd = s.command.trim();
if acp::split_command(cmd).is_empty() {
return Err(tr("ui.ai_need_command").to_string());
}
c.kind = Kind::Acp;
c.command = cmd.chars().take(4096).collect();
c.id = agents::new_id(&app.preferences, "custom");
}
AddKind::Api => {
c.kind = Kind::Api;
c.base_url = agents::normalize_url(&s.base_url)?;
c.model = s.model.trim().to_string();
if c.model.is_empty() {
return Err(tr("ui.ai_need_model").to_string());
}
key = Some(s.key.trim().to_string()).filter(|k| !k.is_empty());
if key.is_some() && !agents::key_safe_for(&c.base_url) {
return Err(tr("ui.ai_key_needs_https").to_string());
}
c.id = agents::new_id(&app.preferences, "api");
}
}
if c.name.is_empty() {
c.name = c.id.clone();
}
if let Some(k) = key {
let Some(store) = &app.services.secrets else { return Err(tr("ui.ai_no_keychain").to_string()) };
store.set(&c.id, &k).map_err(|e| trf("ui.ai_keychain_error", &[("error", &e)]))?;
c.key = true;
}
app.preferences.agents.push(c);
Ok(())
}