1062 lines
50 KiB
Rust
1062 lines
50 KiB
Rust
//! The agent chat panel: docked left or right (switched by dragging its header to the other side,
|
||
//! or in Preferences), resizable, folded to a thin strip with Minimise, closed with ×. Its side,
|
||
//! width and fold are preferences, so they come back next time.
|
||
|
||
use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2};
|
||
|
||
use super::*;
|
||
use crate::theme::Tokens;
|
||
|
||
pub const MIN_WIDTH: f32 = 280.0;
|
||
pub const MAX_WIDTH: f32 = 760.0;
|
||
/// The folded panel's width.
|
||
const STRIP: f32 = 30.0;
|
||
/// The keyboard shortcut that shows and hides the panel (also from its own text field).
|
||
pub const SHORTCUT: egui::KeyboardShortcut = egui::KeyboardShortcut::new(egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT), egui::Key::A);
|
||
|
||
fn panel_id(left: bool) -> Id {
|
||
Id::new(if left { "sc_agent_chat_left" } else { "sc_agent_chat_right" })
|
||
}
|
||
|
||
/// Forget the panel's own idea of its width, so the preference applies on the next frame.
|
||
pub fn forget_width(ctx: &egui::Context) {
|
||
ctx.data_mut(|d| {
|
||
for left in [true, false] {
|
||
d.remove::<egui::containers::panel::PanelState>(panel_id(left));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Show or hide the panel (a folded panel unfolds first).
|
||
pub fn toggle(app: &mut SolveApp) {
|
||
let c = &mut app.agent_chat;
|
||
if !c.open {
|
||
c.open = true;
|
||
app.preferences.agent_chat_minimized = false;
|
||
} else if app.preferences.agent_chat_minimized {
|
||
app.preferences.agent_chat_minimized = false;
|
||
} else {
|
||
c.open = false;
|
||
}
|
||
}
|
||
|
||
/// The tooltip of the chat's buttons: its name and shortcut.
|
||
pub fn tip() -> String {
|
||
let key = if cfg!(target_os = "macos") { "Cmd+Shift+A" } else { "Ctrl+Shift+A" };
|
||
format!("{} ({key})", tr("ui.agent_chat"))
|
||
}
|
||
|
||
/// The panel, when the chat is turned on and open. Call before the central viewport is laid out.
|
||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
let ctx = ui.ctx().clone();
|
||
poll(app, &ctx);
|
||
// With AI features off nothing of the chat is drawn, and the shortcut does nothing.
|
||
if !shown(app) {
|
||
return;
|
||
}
|
||
super::settings::dialog(app, &ctx);
|
||
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
|
||
toggle(app);
|
||
}
|
||
// A window that isn't drawn this frame closes; the next one opens at the saved place.
|
||
if !(app.agent_chat.open && app.preferences.agent_chat_popped) {
|
||
app.agent_chat.window_placed = false;
|
||
}
|
||
// New chat, when the chat has focus (its input, or the pointer over the panel).
|
||
let in_chat = ctx.memory(|m| m.has_focus(Id::new("sc_agent_chat_input")))
|
||
|| ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p)));
|
||
if app.agent_chat.open && in_chat && ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
|
||
request_new_chat(app);
|
||
}
|
||
if !app.agent_chat.open {
|
||
return;
|
||
}
|
||
// Look for installed agents the first time the panel opens (in the background).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||
refresh(app, &ctx);
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if app.preferences.agent_chat_popped {
|
||
window(app, &ctx);
|
||
return;
|
||
}
|
||
let t = Tokens::get();
|
||
let left = app.preferences.agent_chat_side == "left";
|
||
let side = |id: Id| if left { egui::Panel::left(id) } else { egui::Panel::right(id) };
|
||
if app.preferences.agent_chat_minimized {
|
||
side(Id::new("sc_agent_chat_strip")).exact_size(STRIP).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| strip(app, ui));
|
||
return;
|
||
}
|
||
let width = app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH);
|
||
let r = side(panel_id(left))
|
||
.default_size(width)
|
||
.size_range(MIN_WIDTH..=MAX_WIDTH)
|
||
.resizable(true)
|
||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6)))
|
||
.show(ui, |ui| body(app, ui, left));
|
||
crate::scenario::publish_rect("agent_chat:panel", r.response.rect);
|
||
app.agent_chat.panel_rect = Some(r.response.rect);
|
||
let w = r.response.rect.width();
|
||
if w.is_finite() && (MIN_WIDTH..=MAX_WIDTH).contains(&w) {
|
||
app.preferences.agent_chat_width = w;
|
||
}
|
||
}
|
||
|
||
/// New chat (when the chat has focus).
|
||
pub const NEW_CHAT_SHORTCUT: egui::KeyboardShortcut =
|
||
egui::KeyboardShortcut::new(egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT), egui::Key::L);
|
||
|
||
/// Clear the chat, asking first when something would be lost.
|
||
fn request_new_chat(app: &mut SolveApp) {
|
||
if new_chat_needs_confirm(app) {
|
||
app.agent_chat.confirm_new = true;
|
||
} else {
|
||
new_chat(app);
|
||
}
|
||
}
|
||
|
||
/// A saved window place (`[x, y, width, height]`) that can be used: finite, a usable size, and
|
||
/// not absurdly far away. `x` and `y` may be negative: a screen left of or above the main one.
|
||
pub fn window_place(saved: Option<[f32; 4]>) -> Option<([f32; 2], [f32; 2])> {
|
||
let [x, y, w, h] = saved?;
|
||
let usable = [x, y, w, h].iter().all(|v| v.is_finite())
|
||
&& (MIN_WIDTH..=10_000.0).contains(&w)
|
||
&& (200.0..=10_000.0).contains(&h)
|
||
&& x.abs() <= 100_000.0
|
||
&& y.abs() <= 100_000.0;
|
||
usable.then_some(([x, y], [w, h]))
|
||
}
|
||
|
||
/// The chat window's settings. The saved place and size go only to a window that is opening:
|
||
/// egui turns a changed place into a move, and the system rounds what it is given, so giving it
|
||
/// every frame made the window chase its own rounding (it shook a pixel up and down).
|
||
pub fn window_builder(app: &SolveApp) -> egui::ViewportBuilder {
|
||
let title = format!("{} — {}", app.session.doc.name, tr("ui.agent_chat"));
|
||
let builder = egui::ViewportBuilder::default().with_title(title).with_min_inner_size([MIN_WIDTH, 320.0]);
|
||
if app.agent_chat.window_placed {
|
||
return builder;
|
||
}
|
||
match window_place(app.preferences.agent_chat_window) {
|
||
Some((pos, size)) => builder.with_position(pos).with_inner_size(size),
|
||
None => builder.with_inner_size([app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH), 720.0]),
|
||
}
|
||
}
|
||
|
||
/// The chat in its own window, named after the design: a native window on the desktop, which can
|
||
/// go to another screen, or a window floating inside the app where the system can't open one.
|
||
/// The system's close button closes the chat (it opens there again); Dock puts it back.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn window(app: &mut SolveApp, ctx: &egui::Context) {
|
||
let builder = window_builder(app);
|
||
app.agent_chat.window_placed = true;
|
||
// The side panel isn't drawn: its place no longer counts as "in the chat".
|
||
app.agent_chat.panel_rect = None;
|
||
ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("sc_agent_chat_window"), builder, |ui, class| {
|
||
let t = Tokens::get();
|
||
let frame = egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6));
|
||
let r = egui::CentralPanel::default().frame(frame).show(ui, |ui| body(app, ui, false));
|
||
if class == egui::ViewportClass::EmbeddedWindow {
|
||
// Floating inside the app: its handles are the main window's, as the panel's are.
|
||
crate::scenario::publish_rect("agent_chat:window", r.response.rect);
|
||
app.agent_chat.panel_rect = Some(r.response.rect);
|
||
return;
|
||
}
|
||
let ctx = ui.ctx().clone();
|
||
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
|
||
toggle(app);
|
||
}
|
||
// The whole window is the chat, so New chat works anywhere in it.
|
||
if ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
|
||
request_new_chat(app);
|
||
}
|
||
let (outer, inner, close) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect, i.viewport().close_requested()));
|
||
if let (Some(o), Some(n)) = (outer, inner) {
|
||
app.preferences.agent_chat_window = Some([o.min.x, o.min.y, n.width(), n.height()]);
|
||
}
|
||
if close {
|
||
app.agent_chat.open = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Whether the chat's own window should become active because the pointer is over its text box:
|
||
/// only when it is in a window of its own that isn't active and the app's main window is (never
|
||
/// taking the focus from another app).
|
||
pub fn take_focus_for_text(popped: bool, over_text: bool, chat_active: Option<bool>, main_active: Option<bool>) -> bool {
|
||
popped && over_text && chat_active == Some(false) && main_active == Some(true)
|
||
}
|
||
|
||
/// A small drawn button: `kind` is `minimize`, `close`, `newchat`, `stopturn`, `pop_out` or `dock`.
|
||
fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
|
||
let t = Tokens::get();
|
||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||
if resp.hovered() {
|
||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||
}
|
||
let c = r.center();
|
||
let s = Stroke::new(1.4, t.text);
|
||
let p = ui.painter();
|
||
match kind {
|
||
"minimize" => {
|
||
p.line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s);
|
||
}
|
||
"close" => {
|
||
p.line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s);
|
||
p.line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s);
|
||
}
|
||
// A window with an arrow leaving it by its top right corner.
|
||
"pop_out" => {
|
||
p.line_segment([c + vec2(-1.0, -5.0), c + vec2(-5.0, -5.0)], s);
|
||
p.line_segment([c + vec2(-5.0, -5.0), c + vec2(-5.0, 5.0)], s);
|
||
p.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, 5.0)], s);
|
||
p.line_segment([c + vec2(5.0, 5.0), c + vec2(5.0, 1.0)], s);
|
||
p.line_segment([c + vec2(-1.0, 1.0), c + vec2(5.5, -5.5)], s);
|
||
p.line_segment([c + vec2(1.5, -5.5), c + vec2(5.5, -5.5)], s);
|
||
p.line_segment([c + vec2(5.5, -5.5), c + vec2(5.5, -1.5)], s);
|
||
}
|
||
// A window with its right side filled: the panel docked.
|
||
"dock" => {
|
||
p.rect_stroke(Rect::from_center_size(c, vec2(11.0, 10.0)), 1.0, s, egui::StrokeKind::Middle);
|
||
p.rect_filled(Rect::from_min_max(c + vec2(1.5, -5.0), c + vec2(5.5, 5.0)), 0.0, t.text);
|
||
}
|
||
"newchat" => {
|
||
// A speech bubble with a plus.
|
||
ui.painter().rect_stroke(Rect::from_center_size(c, vec2(14.0, 11.0)), 2.5, s, egui::StrokeKind::Inside);
|
||
ui.painter().line_segment([c + vec2(-2.5, 0.0), c + vec2(2.5, 0.0)], Stroke::new(1.2, t.text));
|
||
ui.painter().line_segment([c + vec2(0.0, -2.5), c + vec2(0.0, 2.5)], Stroke::new(1.2, t.text));
|
||
}
|
||
"stopturn" => {
|
||
ui.painter().rect_filled(Rect::from_center_size(c, vec2(9.0, 9.0)), 1.5, t.error);
|
||
}
|
||
_ => {}
|
||
}
|
||
crate::scenario::publish_handle(&format!("agent_chat:{kind}"), r.center());
|
||
resp.on_hover_text(tip)
|
||
}
|
||
|
||
fn state_color(state: State) -> Color32 {
|
||
let t = Tokens::get();
|
||
match state {
|
||
State::Ready | State::Busy => t.accent,
|
||
State::NeedsAuth | State::Exited => t.warning,
|
||
_ => t.text_dim,
|
||
}
|
||
}
|
||
|
||
/// The folded panel: the chat icon (unfolds it), the agent's state, and a mark while something
|
||
/// waits for the user.
|
||
fn strip(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get();
|
||
let r = ui.max_rect();
|
||
let button = Rect::from_center_size(pos2(r.center().x, r.top() + 22.0), vec2(26.0, 26.0));
|
||
let resp = ui.interact(button, Id::new("sc_agent_chat_unfold"), Sense::click());
|
||
crate::scenario::publish_handle("agent_chat:unfold", button.center());
|
||
if resp.hovered() {
|
||
ui.painter().rect_filled(button, 4.0, t.hover);
|
||
}
|
||
crate::icons::paint(ui.painter(), button.shrink(4.0), "agent_chat", t.icon, t.icon_fill, t.accent);
|
||
let state = app.agent_chat.state();
|
||
ui.painter().circle_filled(pos2(r.center().x, button.bottom() + 10.0), 3.5, state_color(state));
|
||
let waiting = app.agent_chat.waiting();
|
||
if waiting > 0 {
|
||
let at = button.right_top() + vec2(-3.0, 3.0);
|
||
ui.painter().circle_filled(at, 6.5, t.warning);
|
||
ui.painter().text(at, Align2::CENTER_CENTER, waiting.min(9).to_string(), egui::FontId::proportional(9.5), t.panel);
|
||
}
|
||
ui.painter().line_segment(
|
||
if app.preferences.agent_chat_side == "left" { [r.right_top(), r.right_bottom()] } else { [r.left_top(), r.left_bottom()] },
|
||
Stroke::new(1.0, t.border),
|
||
);
|
||
let tip = format!("{} · {}", tip(), state.label());
|
||
if resp.on_hover_text(tip).clicked() {
|
||
app.preferences.agent_chat_minimized = false;
|
||
}
|
||
}
|
||
|
||
/// The header: a grip to drag the panel to the other side, the title and state, Connect or Stop,
|
||
/// Pop out (or Dock, in its own window), Minimise and Close. In its own window the system moves
|
||
/// and minimises it, so there is no grip and no Minimise.
|
||
fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||
let t = Tokens::get();
|
||
let state = app.agent_chat.state();
|
||
let popped = app.preferences.agent_chat_popped;
|
||
ui.horizontal(|ui| {
|
||
let title = {
|
||
let (grip, _) = ui.allocate_exact_size(vec2(if popped { 0.0 } else { 10.0 }, 18.0), Sense::hover());
|
||
if !popped {
|
||
for row in 0..3 {
|
||
for col in 0..2 {
|
||
ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim);
|
||
}
|
||
}
|
||
}
|
||
let l = ui.label(RichText::new(tr("ui.agent_chat")).strong().size(14.0));
|
||
grip.union(l.rect)
|
||
};
|
||
let mut s = state.label().to_string();
|
||
if let Some(turn) = &app.agent_chat.turn
|
||
&& state == State::Busy
|
||
{
|
||
let limit = app.preferences.agent_chat_turn_limit;
|
||
s = format!("{s} {}", trf("ui.agent_chat_changes_of", &[("n", &turn.changes.to_string()), ("limit", &limit.to_string())]));
|
||
}
|
||
ui.label(RichText::new(s).color(state_color(state)).small());
|
||
// Drag the header across the window to dock on the other side.
|
||
if !popped {
|
||
let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag());
|
||
crate::scenario::publish_handle("agent_chat:grip", title.center());
|
||
dock_drag(app, ui, &drag, left);
|
||
}
|
||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||
if glyph_button(ui, "close", tr("ui.agent_chat_close")).clicked() {
|
||
app.agent_chat.open = false;
|
||
}
|
||
if !popped && glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() {
|
||
app.preferences.agent_chat_minimized = true;
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if glyph_button(
|
||
ui,
|
||
"newchat",
|
||
&format!("{} ({})", tr("ui.agent_chat_new"), if cfg!(target_os = "macos") { "Cmd+Shift+L" } else { "Ctrl+Shift+L" }),
|
||
)
|
||
.clicked()
|
||
{
|
||
request_new_chat(app);
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let (kind, tip) = if popped { ("dock", "ui.agent_chat_dock") } else { ("pop_out", "ui.agent_chat_pop_out") };
|
||
if glyph_button(ui, kind, tr(tip)).clicked() {
|
||
app.preferences.agent_chat_popped = !popped;
|
||
}
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let b = if state.running() {
|
||
ui.button(tr("ui.agent_chat_stop")).on_hover_text(tr("ui.agent_chat_stop_tip"))
|
||
} else {
|
||
ui.button(tr("ui.agent_chat_connect"))
|
||
};
|
||
crate::scenario::publish_handle("agent_chat:connect", b.rect.center());
|
||
if b.clicked() {
|
||
if state.running() {
|
||
disconnect(app);
|
||
} else {
|
||
connect(app, ui.ctx());
|
||
}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/// While the header is dragged: the cursor grabs and the side it would dock on lights up; on
|
||
/// release over the other half of the window the panel moves there.
|
||
fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: bool) {
|
||
if drag.hovered() {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||
}
|
||
let screen = ui.ctx().content_rect();
|
||
let target = |x: f32| x < screen.center().x;
|
||
if drag.dragged()
|
||
&& let Some(p) = ui.ctx().pointer_interact_pos()
|
||
{
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||
let to_left = target(p.x);
|
||
let w = app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH);
|
||
let top = screen.top() + crate::toolbar::APP_BAR_HEIGHT;
|
||
let zone = if to_left {
|
||
Rect::from_min_max(pos2(screen.left(), top), pos2(screen.left() + w, screen.bottom()))
|
||
} else {
|
||
Rect::from_min_max(pos2(screen.right() - w, top), pos2(screen.right(), screen.bottom()))
|
||
};
|
||
let t = Tokens::get();
|
||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("sc_agent_chat_dock_hint")));
|
||
painter.rect(zone.shrink(4.0), 8.0, t.accent.gamma_multiply(0.18), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||
painter.text(
|
||
zone.center(),
|
||
Align2::CENTER_CENTER,
|
||
tr(if to_left { "ui.agent_chat_dock_left" } else { "ui.agent_chat_dock_right" }),
|
||
egui::FontId::proportional(14.0),
|
||
t.text,
|
||
);
|
||
}
|
||
if drag.drag_stopped()
|
||
&& let Some(p) = ui.ctx().pointer_interact_pos().or_else(|| ui.ctx().pointer_latest_pos())
|
||
&& target(p.x) != left
|
||
{
|
||
app.preferences.agent_chat_side = if left { "right" } else { "left" }.into();
|
||
}
|
||
}
|
||
|
||
fn body(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
|
||
header(app, ui, left);
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
switcher(app, ui);
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
ui.label(tr("ui.agent_chat_desktop_only"));
|
||
}
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
desktop_body(app, ui);
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
|
||
refresh(app, ui.ctx());
|
||
}
|
||
let state = app.agent_chat.state();
|
||
if app.agent_chat.confirm_new {
|
||
let t = Tokens::get();
|
||
egui::Frame::NONE.fill(t.accent_soft).corner_radius(6.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
ui.label(tr("ui.agent_chat_new_confirm"));
|
||
ui.horizontal(|ui| {
|
||
let yes = ui.button(tr("ui.agent_chat_new_yes"));
|
||
crate::scenario::publish_handle("agent_chat:new_confirm", yes.rect.center());
|
||
if yes.clicked() {
|
||
new_chat(app);
|
||
}
|
||
if ui.button(tr("ui.agent_chat_cancel")).clicked() {
|
||
app.agent_chat.confirm_new = false;
|
||
}
|
||
});
|
||
});
|
||
}
|
||
picker(app, ui);
|
||
if state == State::NeedsAuth {
|
||
auth_box(app, ui);
|
||
}
|
||
ui.separator();
|
||
// The input sits at the bottom; the conversation takes the rest.
|
||
let input_h = 92.0;
|
||
let avail = (ui.available_height() - input_h).max(80.0);
|
||
egui::ScrollArea::vertical().id_salt("sc_agent_chat_scroll").max_height(avail).auto_shrink([false, false]).stick_to_bottom(true).show(ui, |ui| {
|
||
conversation(app, ui);
|
||
});
|
||
ui.separator();
|
||
let busy = state == State::Busy;
|
||
let edit = egui::TextEdit::multiline(&mut app.agent_chat.input)
|
||
.hint_text(tr(if busy { "ui.agent_chat_placeholder_busy" } else { "ui.agent_chat_placeholder" }))
|
||
.desired_rows(2)
|
||
.desired_width(f32::INFINITY)
|
||
.id_salt("sc_agent_chat_input");
|
||
// Enter sends (queues while the agent works); Ctrl/Cmd+Enter sends now; Esc stops. They are
|
||
// read before the field sees them so no newline is typed.
|
||
let focused = ui.memory(|m| m.has_focus(Id::new("sc_agent_chat_input")));
|
||
let (steer, esc) = if focused {
|
||
ui.input_mut(|i| (i.consume_key(egui::Modifiers::COMMAND, egui::Key::Enter), busy && i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)))
|
||
} else {
|
||
(false, false)
|
||
};
|
||
let r = ui.add(edit);
|
||
crate::scenario::publish_rect("agent_chat:input", r.rect);
|
||
// macOS shows a window's cursors only while it is the active window: over the text box of a
|
||
// chat window that isn't, the pointer stays an arrow. Coming from the main window, the chat
|
||
// window becomes active, so the text cursor shows and the first click places the caret.
|
||
let (chat_active, main_active) = (ui.ctx().input(|i| i.viewport().focused), ui.ctx().input_for(egui::ViewportId::ROOT, |i| i.viewport().focused));
|
||
if take_focus_for_text(app.preferences.agent_chat_popped, r.hovered(), chat_active, main_active) {
|
||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Focus);
|
||
}
|
||
let enter = r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift);
|
||
if esc {
|
||
cancel(app);
|
||
}
|
||
ui.horizontal(|ui| {
|
||
let send_b = ui.button(tr(if busy { "ui.agent_chat_queue" } else { "ui.agent_chat_send" }));
|
||
crate::scenario::publish_handle("agent_chat:send", send_b.rect.center());
|
||
if (send_b.clicked() || enter || steer) && !app.agent_chat.input.trim().is_empty() {
|
||
let text = std::mem::take(&mut app.agent_chat.input).trim_end_matches('\n').to_string();
|
||
send(app, ui.ctx(), text);
|
||
if steer {
|
||
send_now(app, ui.ctx());
|
||
}
|
||
} else if steer {
|
||
// Nothing typed: Ctrl+Enter sends what is already queued.
|
||
send_now(app, ui.ctx());
|
||
}
|
||
if busy && glyph_button(ui, "stopturn", tr("ui.agent_chat_stop_turn_tip")).clicked() {
|
||
cancel(app);
|
||
}
|
||
// A picture of the view goes with the message to agents that take pictures.
|
||
let images = app.agent_chat.images || !state.running();
|
||
let attach = ui
|
||
.add_enabled(images, egui::Checkbox::new(&mut app.agent_chat.attach_view, tr("ui.agent_chat_attach_view")))
|
||
.on_hover_text(tr("ui.agent_chat_attach_view_tip"))
|
||
.on_disabled_hover_text(tr("ui.agent_chat_no_images"));
|
||
crate::scenario::publish_handle("agent_chat:attach", attach.rect.center());
|
||
});
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub(super) fn login_chip(ui: &mut egui::Ui, login: &str) {
|
||
let t = Tokens::get();
|
||
let (text, color) = match login {
|
||
"signed_in" => (tr("ui.agent_chat_signed_in"), t.accent),
|
||
"key" => (tr("ui.agent_chat_key_in_env"), t.accent),
|
||
"signed_out" => (tr("ui.agent_chat_not_signed_in"), t.warning),
|
||
_ => return,
|
||
};
|
||
egui::Frame::NONE.stroke(Stroke::new(1.0, color)).corner_radius(8.0).inner_margin(egui::Margin::symmetric(6, 1)).show(ui, |ui| {
|
||
ui.label(RichText::new(text).color(color).size(11.0));
|
||
});
|
||
}
|
||
|
||
/// A small mark for an agent's kind: a terminal prompt for a program, a cloud for an endpoint.
|
||
pub(super) fn kind_icon(ui: &mut egui::Ui, kind: agents::Kind) {
|
||
let t = Tokens::get();
|
||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::hover());
|
||
let p = ui.painter();
|
||
let s = Stroke::new(1.3, t.text_dim);
|
||
match kind {
|
||
agents::Kind::Acp => {
|
||
p.rect_stroke(r.shrink(1.0), 2.0, s, egui::StrokeKind::Inside);
|
||
p.line_segment([r.left_top() + vec2(4.0, 4.0), r.left_top() + vec2(7.0, 7.0)], s);
|
||
p.line_segment([r.left_top() + vec2(7.0, 7.0), r.left_top() + vec2(4.0, 10.0)], s);
|
||
p.line_segment([r.left_top() + vec2(8.5, 10.0), r.left_top() + vec2(12.0, 10.0)], s);
|
||
}
|
||
agents::Kind::Api => {
|
||
let c = r.center();
|
||
p.circle_stroke(c + vec2(-3.0, 1.5), 3.2, s);
|
||
p.circle_stroke(c + vec2(1.0, -1.0), 4.2, s);
|
||
p.circle_stroke(c + vec2(4.0, 2.0), 2.8, s);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The header's second row: the agent this conversation uses (a compact switcher), the agent's
|
||
/// own mode when it has modes, and the state of its login.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get();
|
||
let all = agents::entries(app);
|
||
let current = agents::current(app);
|
||
ui.horizontal(|ui| {
|
||
let label = current.as_ref().map_or_else(
|
||
|| tr(if app.agent_chat.detecting.is_some() { "ui.agent_chat_detecting" } else { "ui.agent_chat_none_found_short" }).to_string(),
|
||
|e| e.name.clone(),
|
||
);
|
||
let models = models_shown(app);
|
||
let w =
|
||
(ui.available_width() - if app.agent_chat.modes.is_empty() { 4.0 } else { 116.0 } - if models { 146.0 } else { 0.0 }).clamp(90.0, 260.0);
|
||
let mut picked: Option<String> = None;
|
||
let mut add = false;
|
||
let combo = egui::ComboBox::from_id_salt("sc_agent_switch").width(w).selected_text(RichText::new(label).strong()).show_ui(ui, |ui| {
|
||
for e in &all {
|
||
let sel = current.as_ref().is_some_and(|c| c.id == e.id);
|
||
let r = ui.horizontal(|ui| {
|
||
kind_icon(ui, e.kind);
|
||
let r = ui.selectable_label(sel, &e.name);
|
||
ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0));
|
||
login_chip(ui, e.login);
|
||
r
|
||
});
|
||
crate::scenario::publish_handle(&format!("agent_chat:agent:{}", e.id), r.inner.rect.center());
|
||
if r.inner.clicked() {
|
||
picked = Some(e.id.clone());
|
||
}
|
||
}
|
||
ui.separator();
|
||
if ui.selectable_label(false, tr("ui.ai_add_agent")).clicked() {
|
||
add = true;
|
||
}
|
||
if ui.selectable_label(false, tr("ui.ai_settings")).clicked() {
|
||
crate::prefs::open_at(app, crate::prefs::PAGE_AI);
|
||
}
|
||
});
|
||
crate::scenario::publish_handle("agent_chat:switcher", combo.response.rect.center());
|
||
combo.response.on_hover_text(tr("ui.ai_switch_tip"));
|
||
if add {
|
||
super::settings::open_add(app);
|
||
}
|
||
if let Some(id) = picked
|
||
&& current.as_ref().is_none_or(|c| c.id != id)
|
||
{
|
||
disconnect(app);
|
||
app.agent_chat.agent_id = Some(id);
|
||
}
|
||
// The agent's own modes (ACP `modes`): its own tools follow them.
|
||
if !app.agent_chat.modes.is_empty() {
|
||
let modes = app.agent_chat.modes.clone();
|
||
let cur = modes.iter().find(|(id, _)| *id == app.agent_chat.mode).map_or_else(|| app.agent_chat.mode.clone(), |(_, n)| n.clone());
|
||
let mut pick: Option<String> = None;
|
||
let r = egui::ComboBox::from_id_salt("sc_agent_mode").width(100.0).selected_text(cur).show_ui(ui, |ui| {
|
||
for (id, name) in &modes {
|
||
if ui.selectable_label(*id == app.agent_chat.mode, name).clicked() {
|
||
pick = Some(id.clone());
|
||
}
|
||
}
|
||
});
|
||
crate::scenario::publish_handle("agent_chat:mode", r.response.rect.center());
|
||
r.response.on_hover_text(tr("ui.ai_agent_mode_tip"));
|
||
if let Some(m) = pick {
|
||
set_mode(app, &m);
|
||
}
|
||
}
|
||
// The agent's models (ACP `models`, or an endpoint's list): shown only when it reports some.
|
||
if models {
|
||
let list = app.agent_chat.models.clone();
|
||
let cur = list.iter().find(|m| m.id == app.agent_chat.model).map_or_else(|| app.agent_chat.model.clone(), |m| m.name.clone());
|
||
let mut pick: Option<String> = None;
|
||
let r = egui::ComboBox::from_id_salt("sc_agent_model").width(140.0).truncate().selected_text(cur).show_ui(ui, |ui| {
|
||
for m in &list {
|
||
let row = ui.selectable_label(m.id == app.agent_chat.model, &m.name);
|
||
let row = if m.description.is_empty() { row } else { row.on_hover_text(&m.description) };
|
||
crate::scenario::publish_handle(&format!("agent_chat:model:{}", m.id), row.rect.center());
|
||
if row.clicked() {
|
||
pick = Some(m.id.clone());
|
||
}
|
||
}
|
||
});
|
||
crate::scenario::publish_handle("agent_chat:model", r.response.rect.center());
|
||
r.response.on_hover_text(tr("ui.agent_chat_model_tip"));
|
||
if let Some(m) = pick {
|
||
set_model(app, &m);
|
||
}
|
||
}
|
||
});
|
||
if let Some(a) = &app.agent_chat.auth_status {
|
||
ui.label(RichText::new(a).color(t.text_dim).size(11.0));
|
||
}
|
||
}
|
||
|
||
/// Before the first connection: what's installed, how to install the rest, and Add agent.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn picker(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get();
|
||
let open = app.agent_chat.picking.unwrap_or(!app.agent_chat.state().running() && app.agent_chat.items.is_empty());
|
||
if !open {
|
||
return;
|
||
}
|
||
let all = agents::entries(app);
|
||
let current = agents::current(app);
|
||
ui.horizontal(|ui| {
|
||
ui.label(RichText::new(tr("ui.agent_chat_pick")).strong());
|
||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||
let refreshing = app.agent_chat.detecting.is_some();
|
||
if ui.add_enabled(!refreshing, egui::Button::new(tr("ui.agent_chat_refresh")).small()).clicked() {
|
||
refresh(app, ui.ctx());
|
||
}
|
||
if ui.small_button(tr("ui.ai_add_agent")).clicked() {
|
||
super::settings::open_add(app);
|
||
}
|
||
});
|
||
});
|
||
if app.agent_chat.detecting.is_some() {
|
||
ui.label(RichText::new(tr("ui.agent_chat_detecting")).color(t.text_dim));
|
||
}
|
||
for e in &all {
|
||
let selected = current.as_ref().is_some_and(|c| c.id == e.id);
|
||
let fill = if selected { t.accent_soft } else { Color32::TRANSPARENT };
|
||
let r = egui::Frame::NONE.fill(fill).corner_radius(5.0).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
ui.horizontal(|ui| {
|
||
kind_icon(ui, e.kind);
|
||
ui.label(RichText::new(&e.name).strong().color(t.text));
|
||
ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0));
|
||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| login_chip(ui, e.login));
|
||
});
|
||
});
|
||
let resp = ui.interact(r.response.rect, Id::new(("sc_agent_pick", &e.id)), Sense::click());
|
||
crate::scenario::publish_handle(&format!("agent_chat:pick:{}", e.id), resp.rect.center());
|
||
if resp.clicked() {
|
||
app.agent_chat.agent_id = Some(e.id.clone());
|
||
}
|
||
}
|
||
let missing: Vec<discover::Missing> = app.agent_chat.detection.as_ref().map(|d| d.missing.clone()).unwrap_or_default();
|
||
if !missing.is_empty() {
|
||
let heading = if all.is_empty() { tr("ui.agent_chat_none_found") } else { tr("ui.agent_chat_not_installed") };
|
||
egui::CollapsingHeader::new(RichText::new(heading).color(t.text_dim)).id_salt("sc_agent_missing").default_open(all.is_empty()).show(
|
||
ui,
|
||
|ui| {
|
||
for m in missing {
|
||
ui.horizontal(|ui| {
|
||
ui.label(RichText::new(m.name).strong());
|
||
if ui.small_button(tr("ui.agent_chat_copy")).on_hover_text(m.install).clicked() {
|
||
ui.ctx().copy_text(m.install.to_string());
|
||
}
|
||
});
|
||
ui.add(egui::Label::new(RichText::new(m.install).monospace().size(11.0).color(t.text_dim)).selectable(true).wrap());
|
||
}
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn auth_box(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get();
|
||
egui::Frame::group(ui.style()).stroke(Stroke::new(1.0, t.warning)).show(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
ui.label(RichText::new(tr("ui.agent_chat_auth_title")).strong());
|
||
ui.label(RichText::new(tr("ui.agent_chat_auth_needed")).color(t.text_dim));
|
||
let methods = app.agent_chat.auth.clone();
|
||
let base = app.agent_chat.argv.iter().map(|a| quote(a)).collect::<Vec<_>>().join(" ");
|
||
if methods.is_empty() {
|
||
ui.label(tr("ui.agent_chat_auth_none"));
|
||
}
|
||
for m in methods {
|
||
ui.add_space(4.0);
|
||
let desc = m.description.trim();
|
||
ui.label(RichText::new(if desc.is_empty() || desc == m.name { m.name.clone() } else { format!("{} — {desc}", m.name) }).strong());
|
||
match &m.kind {
|
||
acp::AuthKind::Terminal { args, .. } => {
|
||
ui.label(tr("ui.agent_chat_auth_terminal"));
|
||
let cmd = format!("{base} {}", args.iter().map(|a| quote(a)).collect::<Vec<_>>().join(" "));
|
||
ui.horizontal_wrapped(|ui| {
|
||
egui::Frame::NONE.fill(t.toolbar).corner_radius(4.0).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
||
ui.add(egui::Label::new(RichText::new(&cmd).monospace().size(11.0)).selectable(true).wrap());
|
||
});
|
||
if ui.small_button(tr("ui.agent_chat_copy")).clicked() {
|
||
ui.ctx().copy_text(cmd.clone());
|
||
}
|
||
});
|
||
}
|
||
acp::AuthKind::Agent => {
|
||
let b = ui.button(trf("ui.agent_chat_auth_agent", &[("name", &m.name)]));
|
||
crate::scenario::publish_handle(&format!("agent_chat:auth:{}", m.id), b.rect.center());
|
||
if b.clicked() {
|
||
app.agent_chat.authenticate(&m.id);
|
||
}
|
||
}
|
||
acp::AuthKind::EnvVar { vars } => {
|
||
ui.label(trf("ui.agent_chat_auth_env", &[("vars", &vars.join(", "))]));
|
||
}
|
||
}
|
||
}
|
||
ui.add_space(4.0);
|
||
if ui.button(tr("ui.agent_chat_auth_retry")).clicked() {
|
||
connect(app, ui.ctx());
|
||
}
|
||
});
|
||
}
|
||
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||
let t = Tokens::get();
|
||
let mut answer: Option<(usize, Option<String>)> = None;
|
||
// Allow (true), Deny (false), Always allow (None).
|
||
let mut approval: Option<(u64, Option<bool>)> = None;
|
||
let mut undo: Option<usize> = None;
|
||
let current_doc = app.session.doc.clone();
|
||
for (n, item) in app.agent_chat.items.iter().enumerate() {
|
||
match item {
|
||
Item::User { text, image } => {
|
||
ui.add_space(6.0);
|
||
egui::Frame::NONE.fill(t.accent_soft).corner_radius(6.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
ui.add(egui::Label::new(RichText::new(text).color(t.text)).selectable(true).wrap());
|
||
if *image {
|
||
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(11.0));
|
||
}
|
||
});
|
||
}
|
||
Item::Agent { thought, text } => {
|
||
if *thought {
|
||
ui.add(egui::Label::new(RichText::new(text).italics().color(t.text_dim)).selectable(true).wrap());
|
||
} else {
|
||
super::markdown_view::show(ui, n, text);
|
||
}
|
||
}
|
||
Item::Tool { title, kind, status, output, .. } => {
|
||
tool_card(ui, n, title, kind, *status, output.as_deref());
|
||
}
|
||
Item::Plan(entries) => {
|
||
for (c, s) in entries {
|
||
let (color, filled) = match s.as_str() {
|
||
"completed" => (t.accent, true),
|
||
"in_progress" => (t.warning, true),
|
||
_ => (t.text_dim, false),
|
||
};
|
||
ui.horizontal(|ui| {
|
||
status_dot(ui, color, filled);
|
||
ui.label(RichText::new(c).color(t.text_dim));
|
||
});
|
||
}
|
||
}
|
||
Item::Permission { title, options, answer: given, .. } => {
|
||
egui::Frame::group(ui.style()).stroke(Stroke::new(1.0, t.accent)).show(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
ui.label(RichText::new(trf("ui.agent_chat_permission", &[("title", title)])).strong());
|
||
match given {
|
||
Some(a) => {
|
||
ui.label(RichText::new(trf("ui.agent_chat_answered", &[("option", a)])).color(t.text_dim));
|
||
}
|
||
None => {
|
||
ui.horizontal_wrapped(|ui| {
|
||
for o in options {
|
||
let b = egui::Button::new(&o.name);
|
||
let b = if o.kind.starts_with("allow") { b.fill(t.accent_soft) } else { b };
|
||
let r = ui.add(b);
|
||
crate::scenario::publish_handle(&format!("agent_chat:permission:{}", o.id), r.rect.center());
|
||
if r.clicked() {
|
||
answer = Some((n, Some(o.id.clone())));
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
Item::Approval { id, title, tool, answer: given } => {
|
||
egui::Frame::group(ui.style()).stroke(Stroke::new(1.5, t.warning)).show(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
ui.label(RichText::new(tr("ui.agent_chat_approval")).strong());
|
||
ui.add(egui::Label::new(RichText::new(title).monospace().size(11.5)).selectable(true).wrap());
|
||
match given {
|
||
Some(true) => {
|
||
ui.label(RichText::new(tr("ui.agent_chat_allowed")).color(t.text_dim));
|
||
}
|
||
Some(false) => {
|
||
ui.label(RichText::new(tr("ui.agent_chat_denied")).color(t.text_dim));
|
||
}
|
||
None => {
|
||
ui.horizontal(|ui| {
|
||
let allow = ui.add(egui::Button::new(tr("ui.agent_chat_allow")).fill(t.accent_soft));
|
||
let always = ui.button(tr("ui.ai_always_allow")).on_hover_text(trf("ui.ai_always_allow_tip", &[("tool", tool)]));
|
||
let deny = ui.button(tr("ui.agent_chat_deny"));
|
||
crate::scenario::publish_handle("agent_chat:allow", allow.rect.center());
|
||
crate::scenario::publish_handle("agent_chat:always", always.rect.center());
|
||
crate::scenario::publish_handle("agent_chat:deny", deny.rect.center());
|
||
if allow.clicked() {
|
||
approval = Some((*id, Some(true)));
|
||
}
|
||
if always.clicked() {
|
||
approval = Some((*id, None));
|
||
}
|
||
if deny.clicked() {
|
||
approval = Some((*id, Some(false)));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
}
|
||
Item::Changes { steps, doc, undone } => {
|
||
ui.horizontal(|ui| {
|
||
let text = trf("ui.agent_chat_changes", &[("n", &steps.to_string())]);
|
||
ui.label(RichText::new(text).color(t.text_dim).size(11.5));
|
||
if *undone {
|
||
ui.label(RichText::new(tr("ui.agent_chat_undone")).color(t.text_dim).size(11.5));
|
||
} else if Arc::ptr_eq(doc, ¤t_doc) {
|
||
let b = ui.button(tr("ui.agent_chat_undo_changes")).on_hover_text(tr("ui.ai_undo_tip"));
|
||
crate::scenario::publish_handle("agent_chat:undo", b.rect.center());
|
||
if b.clicked() {
|
||
undo = Some(n);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
Item::Notice { text, error } => {
|
||
ui.label(RichText::new(text).color(if *error { t.error } else { t.text_dim }));
|
||
}
|
||
}
|
||
ui.add_space(2.0);
|
||
}
|
||
let (mut send_now_clicked, mut send_queue_clicked) = (false, false);
|
||
let mut remove: Option<usize> = None;
|
||
let mut edit_done: Option<(usize, Option<String>)> = None;
|
||
let busy = app.agent_chat.state() == State::Busy;
|
||
let stopped = app.agent_chat.stopped;
|
||
let editing = app.agent_chat.pending_edit.clone();
|
||
let mut start_edit: Option<(usize, String)> = None;
|
||
let mut edit_buf = editing.as_ref().map(|(_, b)| b.clone());
|
||
for (n, p) in app.agent_chat.pending.iter().enumerate() {
|
||
ui.add_space(6.0);
|
||
egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.accent)).corner_radius(6.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
|
||
ui.set_width(ui.available_width());
|
||
ui.horizontal(|ui| {
|
||
let tag = if stopped && !busy { "ui.agent_chat_queued_stopped" } else { "ui.agent_chat_queued" };
|
||
ui.label(RichText::new(tr(tag)).color(t.accent).strong().size(11.0));
|
||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||
let x = ui.add(egui::Button::new(RichText::new("×").size(14.0)).frame(false)).on_hover_text(tr("ui.agent_chat_remove"));
|
||
crate::scenario::publish_handle(&format!("agent_chat:pending_remove:{n}"), x.rect.center());
|
||
if x.clicked() {
|
||
remove = Some(n);
|
||
}
|
||
if editing.as_ref().is_none_or(|(i, _)| *i != n) {
|
||
let e = ui.add(egui::Button::new(RichText::new(tr("ui.agent_chat_edit")).size(11.5)).frame(false));
|
||
crate::scenario::publish_handle(&format!("agent_chat:pending_edit:{n}"), e.rect.center());
|
||
if e.clicked() {
|
||
start_edit = Some((n, p.text.clone()));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
match (&editing, edit_buf.as_mut()) {
|
||
(Some((i, _)), Some(buf)) if *i == n => {
|
||
let te = ui.add(egui::TextEdit::multiline(buf).desired_rows(2).desired_width(f32::INFINITY));
|
||
crate::scenario::publish_rect("agent_chat:pending_input", te.rect);
|
||
ui.horizontal(|ui| {
|
||
if ui.button(tr("ui.agent_chat_save")).clicked() {
|
||
edit_done = Some((n, Some(buf.clone())));
|
||
}
|
||
if ui.button(tr("ui.agent_chat_cancel")).clicked() {
|
||
edit_done = Some((n, None));
|
||
}
|
||
});
|
||
}
|
||
_ => {
|
||
ui.add(egui::Label::new(RichText::new(&p.text).color(t.text)).selectable(true).wrap());
|
||
if p.image {
|
||
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(11.0));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
if !app.agent_chat.pending.is_empty() && editing.is_none() {
|
||
ui.horizontal(|ui| {
|
||
if busy {
|
||
let b = ui.button(tr("ui.agent_chat_send_now")).on_hover_text(tr("ui.agent_chat_send_now_tip"));
|
||
crate::scenario::publish_handle("agent_chat:send_now", b.rect.center());
|
||
send_now_clicked = b.clicked();
|
||
} else {
|
||
let b = ui.add(egui::Button::new(tr("ui.agent_chat_send")).fill(t.accent_soft));
|
||
crate::scenario::publish_handle("agent_chat:send_queue", b.rect.center());
|
||
send_queue_clicked = b.clicked();
|
||
}
|
||
});
|
||
}
|
||
if let Some(start) = start_edit {
|
||
app.agent_chat.pending_edit = Some(start);
|
||
} else if let (Some(buf), Some(slot)) = (edit_buf, app.agent_chat.pending_edit.as_mut()) {
|
||
slot.1 = buf;
|
||
}
|
||
if app.agent_chat.items.is_empty() && app.agent_chat.pending.is_empty() {
|
||
ui.add_space(12.0);
|
||
ui.label(RichText::new(tr("ui.agent_chat_intro")).color(t.text_dim));
|
||
ui.add_space(6.0);
|
||
ui.label(RichText::new(tr("ui.agent_chat_privacy")).color(t.text_dim).size(11.0));
|
||
}
|
||
if let Some((n, text)) = edit_done {
|
||
if let (Some(text), Some(p)) = (text.filter(|t| !t.trim().is_empty()), app.agent_chat.pending.get_mut(n)) {
|
||
p.text = text;
|
||
}
|
||
app.agent_chat.pending_edit = None;
|
||
}
|
||
if let Some(n) = remove {
|
||
remove_pending(app, n);
|
||
}
|
||
if send_now_clicked {
|
||
send_now(app, ui.ctx());
|
||
}
|
||
if send_queue_clicked {
|
||
send_now(app, ui.ctx());
|
||
}
|
||
if let Some((n, o)) = answer {
|
||
answer_permission(app, n, o.as_deref());
|
||
}
|
||
match approval {
|
||
Some((id, Some(allow))) => approve(app, id, allow),
|
||
Some((id, None)) => approve_always(app, id),
|
||
None => {}
|
||
}
|
||
if let Some(n) = undo {
|
||
undo_changes(app, n);
|
||
}
|
||
}
|
||
|
||
/// A tool call as a card: its state, name and kind on one line; a click unfolds what it returned.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn tool_card(ui: &mut egui::Ui, n: usize, title: &str, kind: &str, status: Option<ToolStatus>, output: Option<&str>) {
|
||
let t = Tokens::get();
|
||
let (color, filled, state) = match status {
|
||
Some(ToolStatus::Completed) => (t.accent, true, "ui.ai_tool_done"),
|
||
Some(ToolStatus::Failed) => (t.error, true, "ui.ai_tool_failed"),
|
||
Some(ToolStatus::InProgress) => (t.warning, true, "ui.ai_tool_running"),
|
||
_ => (t.text_dim, false, "ui.ai_tool_waiting"),
|
||
};
|
||
egui::Frame::NONE.fill(t.toolbar).stroke(Stroke::new(1.0, t.border)).corner_radius(6.0).inner_margin(egui::Margin::symmetric(6, 3)).show(
|
||
ui,
|
||
|ui| {
|
||
ui.set_width(ui.available_width());
|
||
let id = ui.make_persistent_id(("sc_agent_tool", n));
|
||
let mut state_ = egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false);
|
||
let head = ui.horizontal(|ui| {
|
||
status_dot(ui, color, filled);
|
||
// The state and kind keep their room at the right; the title is cut short before them.
|
||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||
ui.label(RichText::new(tr(state)).size(10.5).color(color));
|
||
if !kind.is_empty() && kind != "other" {
|
||
ui.label(RichText::new(kind).size(10.5).color(t.text_dim));
|
||
}
|
||
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
||
ui.add(egui::Label::new(RichText::new(title).monospace().size(11.5).color(t.text)).truncate()).on_hover_text(title);
|
||
});
|
||
});
|
||
});
|
||
let click = ui.interact(head.response.rect, id.with("head"), Sense::click());
|
||
crate::scenario::publish_handle(&format!("agent_chat:tool:{n}"), head.response.rect.center());
|
||
if output.is_some() && click.clicked() {
|
||
state_.toggle(ui);
|
||
}
|
||
if output.is_some() {
|
||
click.on_hover_text(tr("ui.ai_tool_unfold"));
|
||
}
|
||
state_.show_body_unindented(ui, |ui| {
|
||
if let Some(o) = output {
|
||
ui.separator();
|
||
egui::ScrollArea::vertical().id_salt(("sc_agent_tool_out", n)).max_height(180.0).show(ui, |ui| {
|
||
ui.add(egui::Label::new(RichText::new(o).monospace().size(11.0)).selectable(true).wrap());
|
||
});
|
||
}
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
/// A small status mark drawn as a shape (no glyph a system font might lack): filled when the
|
||
/// step is running or done, hollow while it waits.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn status_dot(ui: &mut egui::Ui, color: Color32, filled: bool) {
|
||
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
|
||
if filled {
|
||
ui.painter().circle_filled(r.center(), 4.0, color);
|
||
} else {
|
||
ui.painter().circle_stroke(r.center(), 3.5, Stroke::new(1.2, color));
|
||
}
|
||
}
|
||
|
||
/// A custom command as the picker shows it: the program's file name and its arguments, cut short.
|
||
pub(super) fn short_command(line: &str) -> String {
|
||
let argv = acp::split_command(line);
|
||
let mut parts: Vec<String> = argv.iter().map(|a| a.rsplit(['/', '\\']).next().unwrap_or(a).to_string()).collect();
|
||
if parts.is_empty() {
|
||
parts.push(line.to_string());
|
||
}
|
||
let s = parts.join(" ");
|
||
if s.chars().count() > 40 { format!("{}…", s.chars().take(40).collect::<String>()) } else { s }
|
||
}
|
||
|
||
/// Quote an argument for the command field when it holds spaces.
|
||
pub(super) fn quote(a: &str) -> String {
|
||
if a.is_empty() || a.contains(char::is_whitespace) || a.contains('"') { format!("\"{}\"", a.replace('"', "\\\"")) } else { a.to_string() }
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn custom_commands_show_short_in_the_picker() {
|
||
assert_eq!(short_command("python3 -I /very/long/path/to/mock_agent.py"), "python3 -I mock_agent.py");
|
||
assert!(short_command(&"x".repeat(100)).ends_with('…'));
|
||
}
|
||
|
||
#[test]
|
||
fn the_shortcut_shows_folds_and_hides() {
|
||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||
toggle(&mut app);
|
||
assert!(app.agent_chat.open);
|
||
app.preferences.agent_chat_minimized = true;
|
||
toggle(&mut app);
|
||
assert!(app.agent_chat.open && !app.preferences.agent_chat_minimized, "a folded panel unfolds");
|
||
toggle(&mut app);
|
||
assert!(!app.agent_chat.open);
|
||
}
|
||
}
|