use crate::{
model::session::{Command, Usage},
view::root,
};
use bezel::{
gpui::{
self, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, KeyBinding,
Render, ScrollHandle, SharedString, Window, actions, div, prelude::*, px,
},
theme::{self, Glass, SurfaceStyle, TextStyle, Theme, Typeset},
ui::{
icons::{self, Icon},
input::{self, Shape, TextField},
menu::{self, Cursor, Hit, Item},
popover,
surface::Surfaced as _,
tooltip::Tooltip,
widgets::Controls as _,
},
};
actions!(
cydonia_composer,
[Send, CommandNext, CommandPrevious, CommandDismiss]
);
const KEY_CONTEXT: &str = "CydoniaComposer";
pub(crate) const SURFACE: SurfaceStyle = SurfaceStyle::Glass(Glass::Regular);
const WARN_AT: f32 = 0.8;
const PICKER_HEIGHT: f32 = 320.;
pub fn init(cx: &mut App) {
let ctx = Some(KEY_CONTEXT);
cx.bind_keys([
KeyBinding::new("enter", Send, ctx),
KeyBinding::new("shift-enter", input::InsertNewline, ctx),
KeyBinding::new("down", CommandNext, ctx),
KeyBinding::new("up", CommandPrevious, ctx),
KeyBinding::new("escape", CommandDismiss, ctx),
]);
}
#[derive(Clone, PartialEq)]
pub struct Agent {
pub name: SharedString,
pub icon: Option<Icon>,
}
#[derive(Clone, PartialEq, Eq)]
pub enum SwitchId {
Mode,
Config(SharedString),
}
#[derive(Clone, PartialEq)]
pub struct SwitchOption {
pub id: SharedString,
pub name: SharedString,
}
#[derive(Clone, PartialEq)]
pub struct Switch {
pub id: SwitchId,
pub name: SharedString,
pub current: Option<SharedString>,
pub options: Vec<SwitchOption>,
}
pub enum ComposerEvent {
Submit(String),
Cancel,
Agent(usize),
Install,
Switch(SwitchId, SharedString),
}
fn set_to(name: &str, value: Option<SharedString>) -> SharedString {
match value {
Some(value) => format!("{name}: {value}").into(),
None => name.into(),
}
}
pub struct Composer {
field: Entity<TextField>,
command: Option<usize>,
filter: popover::Filter,
commands: Vec<Command>,
scroll: ScrollHandle,
streaming: bool,
agents: Vec<Agent>,
agent: Option<usize>,
switches: Vec<Switch>,
usage: Option<Usage>,
menu: bool,
cursor: Cursor,
}
impl EventEmitter<ComposerEvent> for Composer {}
impl Composer {
pub fn new(cx: &mut Context<Self>) -> Self {
let field = cx.new(|cx| {
TextField::new(cx)
.with_shape(Shape::Grow { min: 1, max: 12 })
.with_frame(false)
.with_key_context(KEY_CONTEXT)
.with_placeholder("message the agent…")
});
cx.observe(&field, |composer: &mut Self, _, cx| composer.reread(cx))
.detach();
Self {
field,
command: None,
filter: popover::Filter::new(Vec::new()),
commands: Vec::new(),
scroll: ScrollHandle::new(),
streaming: false,
agents: Vec::new(),
agent: None,
switches: Vec::new(),
usage: None,
menu: false,
cursor: Cursor::default(),
}
}
pub fn set_placeholder(&mut self, placeholder: &str, cx: &mut Context<Self>) {
self.field
.update(cx, |field, cx| field.set_placeholder(placeholder, cx));
}
pub fn set_commands(&mut self, commands: &[Command], cx: &mut Context<Self>) {
if self.commands == commands {
return;
}
self.commands = commands.to_vec();
self.filter = popover::Filter::new(
commands
.iter()
.map(|command| SharedString::from(format!("/{}", command.name)))
.collect(),
);
self.command = None;
cx.notify();
}
pub fn set_streaming(&mut self, streaming: bool, cx: &mut Context<Self>) {
if self.streaming != streaming {
self.streaming = streaming;
cx.notify();
}
}
pub fn set_agents(&mut self, agents: &[Agent], current: Option<usize>, cx: &mut Context<Self>) {
if self.agents == agents && self.agent == current {
return;
}
self.agents = agents.to_vec();
self.agent = current;
self.close_menu();
cx.notify();
}
pub fn set_switches(&mut self, switches: &[Switch], cx: &mut Context<Self>) {
if self.switches == switches {
return;
}
self.switches = switches.to_vec();
cx.notify();
}
pub fn set_usage(&mut self, usage: Option<Usage>, cx: &mut Context<Self>) {
let same = match (self.usage, usage) {
(Some(held), Some(next)) => held.used == next.used && held.size == next.size,
(None, None) => true,
_ => false,
};
if same {
return;
}
self.usage = usage;
cx.notify();
}
pub fn is_empty(&self, cx: &App) -> bool {
self.field.read(cx).content().trim().is_empty()
}
fn reread(&mut self, cx: &mut Context<Self>) {
let content = self.field.read(cx).content().clone();
let caret = self.field.read(cx).cursor().min(content.len());
self.command = content
.starts_with('/')
.then_some(0)
.filter(|_| !self.filter.items().is_empty())
.filter(|_| !content[1..caret].contains(char::is_whitespace));
if self.command.is_some() {
self.filter.refilter(&content[1..caret]);
self.reveal();
}
cx.notify();
}
fn accept(&mut self, item: usize, cx: &mut Context<Self>) {
let content = self.field.read(cx).content().clone();
let caret = self.field.read(cx).cursor().min(content.len());
let picked = format!("{} ", self.filter.items()[item]);
let rest = content[caret..].to_string();
self.field
.update(cx, |field, cx| field.set_content(picked + &rest, cx));
self.command = None;
cx.notify();
}
pub fn submit(&mut self, cx: &mut Context<Self>) {
if self.command.is_some()
&& let Some(item) = self.filter.active_item()
{
self.accept(item, cx);
return;
}
let content = self.field.read(cx).content().clone();
if content.trim().is_empty() {
return;
}
self.field.update(cx, |field, cx| field.clear(cx));
self.command = None;
cx.emit(ComposerEvent::Submit(content.to_string()));
cx.notify();
}
fn send(&mut self, _: &Send, _: &mut Window, cx: &mut Context<Self>) {
self.submit(cx);
}
fn command_next(&mut self, _: &CommandNext, _: &mut Window, cx: &mut Context<Self>) {
self.filter.step(1);
self.reveal();
cx.notify();
}
fn command_previous(&mut self, _: &CommandPrevious, _: &mut Window, cx: &mut Context<Self>) {
self.filter.step(-1);
self.reveal();
cx.notify();
}
fn reveal(&self) {
if let Some(active) = self.filter.active() {
self.scroll.scroll_to_item(active);
}
}
fn command_dismiss(&mut self, _: &CommandDismiss, _: &mut Window, cx: &mut Context<Self>) {
if self.cursor.ascend() {
} else if self.menu {
self.close_menu();
} else if self.command.take().is_none() {
cx.emit(ComposerEvent::Cancel);
}
cx.notify();
}
fn picker(&self, theme: &Theme, cx: &mut Context<Self>) -> Option<AnyElement> {
self.command?;
let items: Vec<Item> = self
.filter
.filtered()
.iter()
.map(|&item| {
let row = Item::action(self.filter.items()[item].clone());
match self.commands[item].description.trim() {
"" => row,
description => row.with_description(description.to_string()),
}
})
.collect();
if items.is_empty() {
return None;
}
let mut cursor = Cursor::default();
if let Some(active) = self.filter.active() {
cursor.point_at(&items, &[active]);
}
let filtered = self.filter.filtered().to_vec();
Some(popover::anchored_menu_above(
"composer-commands",
menu::card(
theme,
"composer-commands",
&items,
&cursor,
cx,
move |composer, hit, _, cx| match hit {
Hit::Point(path) => {
if let [row] = path[..] {
composer.filter.set_active(row);
cx.notify();
}
}
Hit::Choose(path) => {
if let [row] = path[..]
&& let Some(&item) = filtered.get(row)
{
composer.accept(item, cx);
}
}
Hit::Dismiss => {}
},
)
.id("composer-commands-list")
.max_h(px(PICKER_HEIGHT))
.overflow_y_scroll()
.track_scroll(&self.scroll)
.into_any_element(),
None,
))
}
fn close_menu(&mut self) {
self.menu = false;
self.cursor.clear();
}
fn chip(&self, theme: &Theme, cx: &mut Context<Self>) -> Option<AnyElement> {
let agent = self.agent.and_then(|ix| self.agents.get(ix))?.clone();
let mark = px(root::composer_height() / 2.);
let button = div()
.id("composer-agent")
.size(px(root::composer_height()))
.rounded_full()
.flex()
.items_center()
.justify_center()
.cursor_pointer()
.hover(|button| button.bg(theme.element_hover))
.child(match agent.icon {
Some(icon) => icons::icon(icon)
.size(mark)
.text_color(theme.text_muted)
.into_any_element(),
None => icons::icon(icons::layout::LayoutGrid)
.size(mark)
.text_color(theme.text_muted)
.into_any_element(),
})
.on_click(cx.listener(|composer, _, _, cx| {
composer.menu = !composer.menu;
composer.cursor.clear();
cx.notify();
}));
Some(
div()
.relative()
.flex_none()
.child(button.surface(theme, SURFACE))
.children(self.menu_card(theme, cx))
.into_any_element(),
)
}
fn menu_card(&self, theme: &Theme, cx: &mut Context<Self>) -> Option<AnyElement> {
if !self.menu {
return None;
}
let items = self.menu_items();
let rows = items.clone();
let card = menu::card(
theme,
"composer-menu",
&items,
&self.cursor,
cx,
move |composer, hit, _, cx| composer.hit(&rows, hit, cx),
)
.child(popover::divider())
.child(self.usage_row(theme));
Some(popover::anchored_menu_above(
"composer-menu",
card.into_any_element(),
None,
))
}
fn menu_items(&self) -> Vec<Item> {
let current = self.agent.and_then(|ix| self.agents.get(ix));
let mut agents: Vec<Item> = self
.agents
.iter()
.enumerate()
.map(|(ix, agent)| {
let row = Item::action(agent.name.clone()).checked(Some(ix) == self.agent);
match agent.icon.clone() {
Some(mark) => row.with_icon(mark),
None => row,
}
})
.collect();
if !agents.is_empty() {
agents.push(Item::Separator);
}
agents.push(Item::action("Install an agent…").with_icon(icons::files::Download));
let mut items = vec![Item::submenu(
set_to("Agent", current.map(|agent| agent.name.clone())),
agents,
)];
items.extend(self.switches.iter().map(|switch| {
Item::submenu(
set_to(&switch.name, self.value_of(switch)),
switch
.options
.iter()
.map(|option| {
Item::action(option.name.clone())
.checked(switch.current.as_ref() == Some(&option.id))
})
.collect(),
)
}));
items
}
fn hit(&mut self, items: &[Item], hit: Hit, cx: &mut Context<Self>) {
match hit {
Hit::Point(path) => {
if self.cursor.point_at(items, &path) {
cx.notify();
}
}
Hit::Choose(path) => {
let [row, at] = path[..] else { return };
self.close_menu();
if row == 0 {
match at < self.agents.len() {
true => cx.emit(ComposerEvent::Agent(at)),
false => cx.emit(ComposerEvent::Install),
}
} else if let Some(switch) = self.switches.get(row - 1)
&& let Some(option) = switch.options.get(at)
{
cx.emit(ComposerEvent::Switch(switch.id.clone(), option.id.clone()));
}
cx.notify();
}
Hit::Dismiss => {
self.close_menu();
cx.notify();
}
}
}
fn value_of(&self, switch: &Switch) -> Option<SharedString> {
let current = switch.current.as_ref()?;
switch
.options
.iter()
.find(|option| &option.id == current)
.map(|option| option.name.clone())
}
fn usage_row(&self, theme: &Theme) -> AnyElement {
let row = div()
.id("composer-usage")
.px(px(8.))
.py(px(6.))
.flex()
.flex_row()
.items_center()
.gap(px(10.))
.text_style(TextStyle::Body)
.text_color(theme.text_muted)
.child(div().flex_1().min_w_0().child("Context"));
let Some((usage, fraction)) = self
.usage
.and_then(|usage| Some((usage, usage.fraction()?)))
else {
return row
.child(
div()
.text_style(TextStyle::Caption)
.text_color(theme.text_faint)
.child("—"),
)
.into_any_element();
};
let percent = (fraction * 100.).round() as u32;
let (used, size) = (usage.used, usage.size);
row
.tooltip(move |window, cx| {
Tooltip::text(format!("{used} of {size} tokens"), window, cx)
})
.child(
div()
.w(px(44.))
.flex_none()
.child(theme.progress_bar(fraction)),
)
.child(
div()
.text_style(TextStyle::Caption)
.text_color(match fraction >= WARN_AT {
true => theme.warning,
false => theme.text_faint,
})
.child(format!("{percent}%")),
)
.into_any_element()
}
fn button(&self, theme: &Theme, cx: &mut Context<Self>) -> AnyElement {
let streaming = self.streaming;
let ready = streaming || !self.is_empty(cx);
let glyph = if streaming {
icons::multimedia::CircleStop
} else {
icons::arrows::ArrowUp
};
let glyph_size = px(root::composer_disc() / 2.);
let disc = div()
.flex_none()
.size(px(root::composer_disc()))
.rounded_full()
.flex()
.items_center()
.justify_center();
let disc = if ready {
disc.bg(if streaming { theme.danger } else { theme.solid })
.cursor_pointer()
.hover(|s| s.opacity(0.9))
.child(
icons::icon(glyph)
.size(glyph_size)
.text_color(if streaming {
theme.on_accent
} else {
theme.on_solid
}),
)
} else {
disc.bg(theme::ink(0.06)).child(
icons::icon(glyph)
.size(glyph_size)
.text_color(theme.text_faint),
)
};
div()
.id("composer-send")
.flex_none()
.on_click(cx.listener(|composer, _, _, cx| {
if composer.streaming {
cx.emit(ComposerEvent::Cancel);
} else {
composer.submit(cx);
}
}))
.child(disc)
.into_any_element()
}
fn body(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::of(cx).clone();
let picker = self.picker(&theme, cx);
let radius = px(root::composer_height() / 2.);
div()
.on_action(cx.listener(Self::send))
.on_action(cx.listener(Self::command_next))
.on_action(cx.listener(Self::command_previous))
.on_action(cx.listener(Self::command_dismiss))
.flex()
.flex_col()
.child(
div()
.w_full()
.flex()
.flex_row()
.items_end()
.gap(px(root::COMPOSER_INSET))
.children(self.chip(&theme, cx))
.child(
div()
.relative()
.flex_1()
.min_w_0()
.child(
div()
.w_full()
.rounded(radius)
.p(px(root::COMPOSER_INSET))
.flex()
.flex_row()
.items_end()
.gap(px(root::COMPOSER_INSET))
.child(
div()
.flex_1()
.min_w_0()
.min_h(px(root::composer_disc()))
.flex()
.items_center()
.child(self.field.clone()),
)
.child(self.button(&theme, cx))
.surface(&theme, SURFACE),
)
.children(picker),
),
)
}
}
impl Focusable for Composer {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.field.read(cx).focus_handle(cx)
}
}
impl Render for Composer {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
self.body(cx)
}
}