use crate::{
model::session::{ChatSession, Choice},
view::{
component::{composer, transcript},
root::{self, Cydonia, NewSession, Pane},
settings::Section,
},
};
use artifact::session::chat::PlanStatus;
use bezel::{
gpui::{
AnyElement, App, Context, FocusHandle, Focusable as _, SharedString, Window, div,
prelude::*, px,
},
motion::{Fade, Painter},
theme::{TextStyle, Theme, Typeset},
ui::{
icons, surface,
widgets::{ButtonStyle, Buttons, Content, Controls, Status},
},
};
use cacp::schema::{
PermissionOptionKind, SessionConfigKind, SessionConfigOptionCategory, SessionConfigOptionValue,
SessionConfigSelectOption, SessionConfigSelectOptions, SessionModeState,
};
use std::path::Path;
use surface::Surfaced as _;
fn switches(chat: &ChatSession) -> Vec<composer::Switch> {
let mut switches: Vec<composer::Switch> = chat
.config
.iter()
.filter_map(|option| {
let SessionConfigKind::Select(select) = &option.kind else {
return None;
};
let options = select_options(&select.options);
(!options.is_empty()).then(|| composer::Switch {
id: composer::SwitchId::Config(option.id.to_string().into()),
name: option.name.clone().into(),
current: Some(select.current_value.to_string().into()),
options,
})
})
.collect();
if let Some(modes) = chat
.modes
.as_ref()
.filter(|modes| !modes.available_modes.is_empty())
.filter(|modes| !covered_by_config(chat, modes))
{
switches.push(composer::Switch {
id: composer::SwitchId::Mode,
name: "Mode".into(),
current: Some(modes.current_mode_id.to_string().into()),
options: modes
.available_modes
.iter()
.map(|mode| composer::SwitchOption {
id: mode.id.to_string().into(),
name: mode.name.clone().into(),
})
.collect(),
});
}
switches
}
fn covered_by_config(chat: &ChatSession, modes: &SessionModeState) -> bool {
chat.config.iter().any(|option| {
if option.category == Some(SessionConfigOptionCategory::Mode) {
return true;
}
let SessionConfigKind::Select(select) = &option.kind else {
return false;
};
let values = select_options(&select.options);
modes
.available_modes
.iter()
.all(|mode| values.iter().any(|value| value.id.as_ref() == &*mode.id))
})
}
fn select_options(options: &SessionConfigSelectOptions) -> Vec<composer::SwitchOption> {
fn one(option: &SessionConfigSelectOption) -> composer::SwitchOption {
composer::SwitchOption {
id: option.value.to_string().into(),
name: option.name.clone().into(),
}
}
match options {
SessionConfigSelectOptions::Ungrouped(options) => options.iter().map(one).collect(),
SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.flat_map(|group| group.options.iter().map(one))
.collect(),
}
}
fn shown_path(path: &Path) -> String {
let full = path.display().to_string();
dirs::home_dir()
.and_then(|home| {
full.strip_prefix(home.to_str()?)
.map(|rest| format!("~{rest}"))
})
.unwrap_or(full)
}
pub struct Verdict<'a> {
label: &'a str,
once: &'a str,
always: Option<&'a str>,
}
impl Verdict<'_> {
pub fn id(&self, always: bool) -> String {
match always {
true => self.always.unwrap_or(self.once).to_owned(),
false => self.once.to_owned(),
}
}
}
pub fn alert(options: &[Choice]) -> Option<(Verdict<'_>, Verdict<'_>)> {
let deny = verdict(options, false)?;
let allow = verdict(options, true)?;
let covered = 2 + usize::from(deny.always.is_some()) + usize::from(allow.always.is_some());
(covered == options.len()).then_some((deny, allow))
}
fn verdict(options: &[Choice], allow: bool) -> Option<Verdict<'_>> {
let (once, ever) = match allow {
true => (
PermissionOptionKind::AllowOnce,
PermissionOptionKind::AllowAlways,
),
false => (
PermissionOptionKind::RejectOnce,
PermissionOptionKind::RejectAlways,
),
};
let of = |kind: PermissionOptionKind| options.iter().find(move |o| o.kind == kind);
let once = of(once)?;
Some(Verdict {
label: &once.name,
once: &once.id,
always: of(ever).map(|option| option.id.as_str()),
})
}
pub fn adrift(agent: Option<&str>, others: bool) -> &'static str {
match (agent, others) {
(Some(_), true) => "Open a session on an agent that is, or install this one again.",
(Some(_), false) => "No other agent is installed either.",
(None, _) => "Install one to open a session here.",
}
}
pub fn agent_missing(agent: Option<&str>) -> String {
match agent {
Some(agent) => format!("{agent} is not installed"),
None => "No agent installed".to_owned(),
}
}
pub fn adrift_line(agent: &str, others: bool) -> String {
let missing = agent_missing(Some(agent));
match others {
true => format!(
"{missing} — open a session on an agent that is, or install it again in Settings › Agents."
),
false => format!("{missing} — install one in Settings › Agents."),
}
}
impl Cydonia {
pub fn composer_focus_handle(&self, cx: &App) -> FocusHandle {
self.composer.focus_handle(cx)
}
fn with_active(&mut self, cx: &mut Context<Self>, f: impl FnOnce(&mut ChatSession)) {
let Some(id) = self.workspace.read(cx).active_id() else {
return;
};
self.workspace
.update(cx, |workspace, cx| workspace.with_session(id, cx, f));
}
pub(crate) fn submit(&mut self, text: String, cx: &mut Context<Self>) {
let Some(id) = self.workspace.read(cx).active_id() else {
return;
};
self.workspace
.update(cx, |workspace, cx| workspace.send(id, text, cx));
}
pub(crate) fn cancel_turn(&mut self, cx: &mut Context<Self>) {
self.with_active(cx, |chat| chat.cancel());
}
pub(crate) fn pick_agent(&mut self, ix: usize, cx: &mut Context<Self>) {
let entry = self.workspace.read(cx).settings.agents.get(ix).cloned();
if let Some(entry) = entry {
self.show_pane(Pane::Chat, cx);
self.workspace
.update(cx, |workspace, cx| workspace.new_session(entry, None, cx));
}
}
pub(crate) fn sync_composer(&mut self, cx: &mut Context<Self>) {
let workspace = self.workspace.read(cx);
let agents: Vec<composer::Agent> = workspace
.settings
.agents
.iter()
.map(|entry| composer::Agent {
name: entry.name.clone().into(),
icon: workspace.agent_icon(&entry.name),
})
.collect();
let chat = workspace.active_session();
let placeholder = chat.map_or_else(
|| "message the agent…".to_owned(),
|chat| format!("message {}…", chat.entry.name),
);
let commands = chat.map(|chat| chat.commands.clone()).unwrap_or_default();
let streaming = chat.is_some_and(|chat| chat.streaming);
let current = chat
.map(|chat| chat.entry.name.clone())
.and_then(|name| agents.iter().position(|agent| agent.name == name));
let live = chat.filter(|chat| chat.live());
let switches = live.map(switches).unwrap_or_default();
let usage = live.and_then(|chat| chat.usage);
self.composer.update(cx, |composer, cx| {
composer.set_placeholder(&placeholder, cx);
composer.set_commands(&commands, cx);
composer.set_streaming(streaming, cx);
composer.set_agents(&agents, current, cx);
composer.set_switches(&switches, cx);
composer.set_usage(usage, cx);
});
}
pub(crate) fn detail(
&self,
window: &mut Window,
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
let theme = Theme::of(cx).clone();
let live = self.workspace.read(cx).reachable();
let showing = self.showing(cx);
let body = match showing {
None => self.launch(cx),
Some(Pane::Chat) => self.conversation(window, cx),
Some(Pane::Board) => self.board(window, cx),
Some(Pane::Article) => self.article(window, cx).unwrap_or_else(|| self.launch(cx)),
Some(Pane::Table) => self.table(cx).unwrap_or_else(|| self.launch(cx)),
};
let content = div()
.flex_1()
.min_h_0()
.flex()
.flex_col()
.overflow_hidden()
.pt(px(root::HEADER_HEIGHT))
.child(body);
div()
.flex_1()
.min_w_0()
.relative()
.bg(root::content_bg(&theme))
.flex()
.flex_col()
.child(content)
.child(self.pane_header(window, cx))
.when(showing == Some(Pane::Chat), |column| match live {
true => column.child(footer(
div()
.flex()
.flex_col()
.gap(px(8.))
.children(self.plan(cx))
.children(self.permission(cx))
.children(self.queue(cx))
.child(self.composer.clone()),
)),
false => column.children(self.adrift_strip(cx).map(footer)),
})
}
}
fn footer(inner: impl IntoElement) -> impl IntoElement {
div()
.absolute()
.bottom(px(root::COMPOSER_BOTTOM))
.left_0()
.right_0()
.flex()
.justify_center()
.child(
div()
.w_full()
.max_w(px(root::COMPOSER_COLUMN))
.px(px(root::COMPOSER_MARGIN))
.child(inner),
)
}
fn make_list(rows: impl IntoIterator<Item = AnyElement>) -> impl IntoElement {
div()
.mt(px(14.))
.flex()
.flex_col()
.items_start()
.gap(px(8.))
.children(rows)
}
impl Cydonia {
fn launch(&self, cx: &mut Context<Self>) -> AnyElement {
match self.workspace.read(cx).active_project().is_some() {
true => self.nothing_open(cx),
false => self.no_project(cx),
}
}
fn nothing_open(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let workspace = self.workspace.read(cx);
let Some(ix) = workspace.active else {
return self.no_project(cx);
};
let features = &workspace.settings.features;
let (sessions, boards, tables) = (features.sessions, features.boards, features.tables);
let name = workspace
.projects
.get(ix)
.map(|project| shown_path(&project.path))
.unwrap_or_default();
let mut rows: Vec<AnyElement> = Vec::new();
if sessions {
rows.push(self.make_row(
"session",
"New session",
icons::social::MessageCircle,
cx,
move |this, window, cx| this.new_session_action(&NewSession, window, cx),
));
}
if boards {
rows.push(self.make_row(
"board",
"New board",
icons::text::List,
cx,
move |this, window, cx| this.ask_new_board(ix, window, cx),
));
}
rows.push(self.make_row(
"article",
"New article",
icons::files::FilePlus,
cx,
move |this, window, cx| this.new_article(ix, window, cx),
));
if tables {
rows.push(self.make_row(
"table",
"New table",
icons::layout::LayoutGrid,
cx,
move |this, _, cx| this.new_table(ix, cx),
));
}
theme
.empty_state(icons::files::Folder, "Nothing open", format!("in {name}"))
.flex_1()
.child(make_list(rows))
.into_any_element()
}
fn make_row(
&self,
id: &'static str,
label: &'static str,
glyph: &'static [u8],
cx: &mut Context<Self>,
make: impl Fn(&mut Self, &mut Window, &mut Context<Self>) + 'static,
) -> AnyElement {
let theme = Theme::of(cx).clone();
div()
.id(id)
.group(id)
.flex()
.items_center()
.gap(px(8.))
.cursor_pointer()
.text_style(TextStyle::Callout)
.child(
icons::icon(glyph)
.size(px(14.))
.flex_none()
.text_color(theme.text_muted)
.group_hover(id, |el| el.text_color(theme.text)),
)
.child(
div()
.text_color(theme.text_muted)
.group_hover(id, |el| el.text_color(theme.text))
.child(label),
)
.on_click(cx.listener(move |this, _, window, cx| make(this, window, cx)))
.into_any_element()
}
fn conversation(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let workspace = self.workspace.read(cx);
let Some(chat) = workspace.active_session() else {
return match self.asked_session {
true => self.no_agent(None, false, cx),
false => div().flex_1().into_any_element(),
};
};
if chat.unsaid() {
let agent = chat.entry.name.clone();
let cwd = workspace
.active_project()
.map(|project| shown_path(&project.path))
.unwrap_or_default();
let others = !workspace.settings.agents.is_empty();
if !workspace.reachable() {
return self.no_agent(Some(&agent), others, cx);
}
return theme
.empty_state(icons::files::Folder, cwd, format!("{agent} runs here"))
.flex_1()
.into_any_element();
}
let id = chat.id;
self.workspace
.update(cx, |workspace, cx| match workspace.session(id) {
Some(chat) => transcript::render(chat, window, cx),
None => div().flex_1().into_any_element(),
})
}
fn no_agent(&self, agent: Option<&str>, others: bool, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let painter = Painter::of(cx);
theme
.empty_state(
icons::files::Download,
agent_missing(agent),
adrift(agent, others),
)
.flex_1()
.child(
theme
.button(
"Install an agent…",
ButtonStyle::Prominent,
Some(Fade::new(painter, "install-agent-empty")),
)
.id("install-agent-empty")
.on_click(
cx.listener(|this, _, _, cx| this.open_settings(Section::Agents, cx)),
),
)
.into_any_element()
}
fn adrift_strip(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let theme = Theme::of(cx).clone();
let workspace = self.workspace.read(cx);
let chat = workspace.active_session().filter(|chat| !chat.unsaid())?;
let message = adrift_line(&chat.entry.name, !workspace.settings.agents.is_empty());
Some(
theme
.warning_strip(message)
.id("adrift")
.mt(px(0.))
.cursor_pointer()
.on_click(cx.listener(|this, _, _, cx| this.open_settings(Section::Agents, cx)))
.into_any_element(),
)
}
fn plan(&self, cx: &Context<Self>) -> Option<impl IntoElement + use<>> {
let theme = Theme::of(cx).clone();
let chat = self.workspace.read(cx).active_session()?;
if chat.plan.is_empty() || chat.plan.iter().all(|(_, s)| *s == PlanStatus::Done) {
return None;
}
Some(
div()
.rounded(px(Theme::surface_radius()))
.px(px(12.))
.py(px(8.))
.flex()
.flex_col()
.gap(px(4.))
.text_style(TextStyle::Callout)
.children(chat.plan.iter().map(|(text, status)| {
let (icon, tone) = match status {
PlanStatus::Done => (icons::notifications::Check, theme.success),
PlanStatus::Active => (icons::arrows::ChevronRight, theme.accent),
PlanStatus::Pending => (icons::text::ListChecks, theme.text_faint),
};
div()
.flex()
.flex_row()
.items_start()
.gap(px(8.))
.text_color(theme.text_muted)
.child(icons::icon(icon).size(px(12.)).text_color(tone))
.child(text.clone())
}))
.surface(&theme, composer::SURFACE),
)
}
fn permission(&self, cx: &Context<Self>) -> Option<impl IntoElement + use<>> {
let theme = Theme::of(cx).clone();
let chat = self.workspace.read(cx).active_session()?;
let prompt = chat.permission.as_ref()?;
let id = chat.id;
let painter = Painter::of(cx);
let answer = |key: &str, option_id: String, label: &str, style| {
let fade = Fade::new(painter, format!("permission-{id}-{key}"));
theme
.button(label.to_owned(), style, Some(fade))
.id(SharedString::from(key.to_owned()))
.on_click(cx.listener(move |this, _, _, cx| {
let option_id = option_id.clone();
this.workspace.update(cx, |workspace, cx| {
workspace.with_session(id, cx, |chat| chat.respond_permission(option_id));
});
}))
};
let body = match alert(&prompt.options) {
Some((deny, allow)) => div()
.flex()
.flex_row()
.items_center()
.gap(px(8.))
.children((deny.always.is_some() || allow.always.is_some()).then(|| {
div()
.id("permission-always")
.flex()
.flex_row()
.items_center()
.gap(px(8.))
.cursor_pointer()
.child(theme.checkbox(prompt.always))
.child(
div()
.text_style(TextStyle::Callout)
.text_color(theme.text_muted)
.child("Always allow"),
)
.on_click(cx.listener(move |this, _, _, cx| {
this.workspace.update(cx, |workspace, cx| {
workspace
.with_session(id, cx, |chat| chat.toggle_permission_always());
});
}))
}))
.child(div().flex_1())
.child(answer(
"deny",
deny.id(prompt.always),
deny.label,
ButtonStyle::Ghost,
))
.child(answer(
"allow",
allow.id(prompt.always),
allow.label,
ButtonStyle::Prominent,
))
.into_any_element(),
None => div()
.flex()
.flex_col()
.gap(px(6.))
.children(prompt.options.iter().enumerate().map(|(ix, option)| {
let style = match option.kind {
PermissionOptionKind::AllowOnce => ButtonStyle::Prominent,
_ => ButtonStyle::Ghost,
};
answer(
&format!("option-{ix}"),
option.id.clone(),
&option.name,
style,
)
.w_full()
.justify_center()
}))
.into_any_element(),
};
Some(
div()
.rounded(px(Theme::surface_radius()))
.px(px(14.))
.py(px(12.))
.flex()
.flex_col()
.gap(px(12.))
.child(
div()
.flex()
.flex_row()
.items_start()
.gap(px(8.))
.child(
icons::icon(icons::security::Key)
.size(px(14.))
.flex_none()
.mt(px(3.))
.text_color(theme.text_muted),
)
.child(
div()
.flex_1()
.min_w_0()
.text_style(TextStyle::Body)
.text_color(theme.text)
.child(prompt.title.clone()),
),
)
.child(body)
.surface(&theme, composer::SURFACE),
)
}
pub(crate) fn switch(
&mut self,
id: &composer::SwitchId,
value: &SharedString,
cx: &mut Context<Self>,
) {
let Some(session) = self.workspace.read(cx).active_session().map(|chat| chat.id) else {
return;
};
let value = value.to_string();
self.workspace.update(cx, |workspace, cx| match id {
composer::SwitchId::Mode => workspace.set_session_mode(session, value, cx),
composer::SwitchId::Config(config) => workspace.set_session_config(
session,
config.to_string(),
SessionConfigOptionValue::ValueId {
value: value.into(),
},
cx,
),
});
cx.notify();
}
fn queue(&self, cx: &Context<Self>) -> Option<impl IntoElement + use<>> {
let theme = Theme::of(cx).clone();
let chat = self.workspace.read(cx).active_session()?;
if chat.queue.is_empty() {
return None;
}
let id = chat.id;
Some(div().flex().flex_col().items_end().gap(px(6.)).children(
chat.queue.iter().enumerate().map(|(ix, text)| {
let group = SharedString::from(format!("steer-{ix}"));
div()
.group(group.clone())
.max_w(px(440.))
.px(px(14.))
.py(px(9.))
.rounded(px(Theme::surface_radius()))
.bg(theme.surface_raised.opacity(0.6))
.flex()
.flex_row()
.items_start()
.gap(px(10.))
.child(
div()
.flex_1()
.min_w_0()
.text_style(TextStyle::Body)
.text_color(theme.text_muted)
.child(text.clone()),
)
.child(
div()
.id(("unqueue", ix))
.flex_none()
.mt(px(4.))
.cursor_pointer()
.child(
icons::icon(icons::notifications::X)
.size(px(12.))
.text_color(theme.text_faint)
.group_hover(group, |el| el.text_color(theme.text)),
)
.on_click(cx.listener(move |this, _, _, cx| {
this.workspace.update(cx, |workspace, cx| {
workspace.with_session(id, cx, |chat| chat.unqueue(ix));
});
})),
)
}),
))
}
}