use crate::{
model::{
session::{ChatSession, Choice},
workspace::Showing,
},
view::{
component::{
composer,
menu::{self, Menu},
ribbon, transcript,
},
leaf::Pane,
root::{self, Cydonia, NewArticle, NewBoard, NewSession, NewTable},
settings::Section,
},
};
use artifact::{session::chat::PlanStatus, space::Member};
use bezel::{
gpui::{
self, AnyElement, App, Axis, Context, Div, DragMoveEvent, Empty, FocusHandle,
Focusable as _, Pixels, Point, SharedString, Stateful, Window, div, prelude::*, px,
},
motion::{Fade, Painter},
theme::{TextStyle, Theme, Typeset},
ui::{
floating,
icons::{self, Icon},
keys,
menu::Item,
popover, surface,
tooltip::Tooltip,
widgets::{ButtonStyle, Buttons, Content, Controls, Status},
},
};
use cacp::schema::{
PermissionOptionKind, SessionConfigKind, SessionConfigOptionCategory, SessionConfigOptionValue,
SessionConfigSelectOption, SessionConfigSelectOptions, SessionModeState,
};
use std::path::{Path, PathBuf};
use surface::Surfaced as _;
struct ChangesResize;
struct TerminalResize;
const CHAT_MIN: f32 = 240.;
const PANEL_MIN: f32 = 280.;
const PANEL_MAX: f32 = 440.;
const PANEL_SHARE: f32 = 0.33;
pub fn panel_beside(available: f32) -> bool {
available >= CHAT_MIN + PANEL_MIN
}
pub fn panel_width(preferred: Option<f32>, available: f32) -> f32 {
let preferred =
preferred.unwrap_or_else(|| (available * PANEL_SHARE).clamp(PANEL_MIN, PANEL_MAX));
let min = PANEL_MIN.min(available / 2.);
let max = (available - CHAT_MIN).min(available / 2.).max(min);
preferred.clamp(min, max)
}
fn panel_height(preferred: f32, available: f32) -> f32 {
let min = 120.0_f32.min(available / 2.);
preferred.clamp(min, (available - 160.).max(min))
}
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(crate) fn shell_cwd(&self, cx: &App) -> Option<PathBuf> {
let workspace = self.workspace.read(cx);
if let Some(space) = workspace.active_space() {
return space.panes().into_iter().next().map(|pane| pane.project);
}
if self.showing(cx) == Some(Pane::Chat)
&& let Some(chat) = workspace.active_session()
{
return Some(chat.cwd.clone());
}
Some(workspace.active_project()?.path.clone())
}
pub(crate) fn show_terminal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.terminal.is_none() {
let Some(cwd) = self.shell_cwd(cx) else {
return;
};
let this = cx.weak_entity();
let panel = cx.new(|cx| {
super::component::terminal::TerminalPanel::new(
&cwd,
move |cx| this.upgrade()?.read(cx).shell_cwd(cx),
window,
cx,
)
});
cx.subscribe_in(
&panel,
window,
|this, _, _: &super::component::terminal::Empty, window, cx| {
if this.terminal.take().is_some_and(|(visible, _)| visible) {
this.focus_after_terminal(window, cx);
}
cx.notify();
},
)
.detach();
self.terminal = Some((false, panel));
}
let Some((visible, panel)) = self.terminal.as_mut() else {
return;
};
*visible = true;
let panel = panel.clone();
window.focus(&panel.focus_handle(cx), cx);
cx.notify();
}
pub(crate) fn toggle_terminal(
&mut self,
_: &root::ToggleTerminal,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some((visible, _)) = self.terminal.as_mut()
&& *visible
{
*visible = false;
self.focus_after_terminal(window, cx);
cx.notify();
} else {
self.show_terminal(window, cx);
}
}
fn focus_after_terminal(&self, window: &mut Window, cx: &mut Context<Self>) {
match self.showing(cx) == Some(Pane::Chat) {
true => window.focus(&self.composer_focus_handle(cx), cx),
false => window.focus(&self.focus, cx),
}
}
pub fn composer_focus_handle(&self, cx: &App) -> FocusHandle {
self.leaf().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,
attachments: Vec<crate::model::media::Attachment>,
cx: &mut Context<Self>,
) {
let Some(id) = self.workspace.read(cx).active_id() else {
return;
};
self.workspace
.update(cx, |workspace, cx| match attachments.is_empty() {
true => workspace.send(id, text, cx),
false => workspace.send_attached(id, text, &attachments, cx),
});
if let Some(chat) = self.workspace.read(cx).session(id) {
chat.transcript.follow_tail();
}
}
fn selection_bar(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let (view, head) = self
.workspace
.read(cx)
.active_session()?
.transcript
.selection_perch()?;
let at = ribbon::perch(view, head)?;
let theme = Theme::of(cx).clone();
let card = popover::popover_card(&theme)
.flex()
.flex_row()
.items_center()
.gap(px(2.))
.child(
self.selection_action("Copy", icons::text::Copy, &theme, cx, |this, _, cx| {
this.workspace.update(cx, |workspace, cx| {
workspace.copy_selection(cx);
workspace.clear_selection(cx);
});
}),
)
.child(self.selection_action(
"Quote",
icons::text::TextQuote,
&theme,
cx,
|this, window, cx| this.quote_selection(window, cx),
));
Some(ribbon::floated(
"transcript-selection",
at,
card.into_any_element(),
))
}
fn selection_action(
&self,
label: &'static str,
glyph: &'static [u8],
theme: &Theme,
cx: &Context<Self>,
act: impl Fn(&mut Self, &mut Window, &mut Context<Self>) + 'static,
) -> AnyElement {
theme
.ghost(SharedString::from(format!("selection-{label}")))
.px(px(8.))
.py(px(4.))
.flex()
.flex_row()
.items_center()
.gap(px(6.))
.text_style(TextStyle::Callout)
.text_color(theme.text)
.child(
icons::icon(glyph)
.size(px(13.))
.flex_none()
.text_color(theme.text_muted),
)
.child(label)
.on_click(cx.listener(move |this, _, window, cx| act(this, window, cx)))
.into_any_element()
}
fn quote_selection(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let text = self
.workspace
.read(cx)
.active_session()
.and_then(|chat| chat.transcript.copied(chat));
let Some(text) = text else {
return;
};
self.leaf()
.composer
.update(cx, |composer, cx| composer.quote(text, cx));
self.workspace
.update(cx, |workspace, cx| workspace.clear_selection(cx));
window.focus(&self.composer_focus_handle(cx), 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, window: &mut Window, cx: &mut Context<Self>) {
let entry = self.workspace.read(cx).settings.agents.get(ix).cloned();
if let Some(entry) = entry {
self.enter_member(None, window, cx);
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 arranged = workspace.active_space().is_some();
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 on: Vec<Option<u64>> = self
.leaves
.iter()
.map(|leaf| match &leaf.entry {
Some(entry) => workspace
.showing_of(entry)
.and_then(|(_, showing)| match showing {
Showing::Session(id) => Some(id),
_ => None,
}),
None => workspace.active_id(),
})
.collect();
let mut pointed = Vec::with_capacity(on.len());
for id in on {
let chat = id.and_then(|id| workspace.session(id));
let placeholder = chat.map_or_else(
|| "message the agent…".to_owned(),
|chat| format!("message {}…", chat.entry.name),
);
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());
pointed.push((
chat.map(|chat| chat.id),
chat.map(|chat| chat.draft.clone()).unwrap_or_default(),
placeholder,
chat.map(|chat| chat.commands.clone()).unwrap_or_default(),
chat.is_some_and(|chat| chat.streaming),
chat.and_then(composer::Activity::of),
current,
live.map(switches).unwrap_or_default(),
live.and_then(|chat| chat.usage),
));
}
for (leaf, point) in self.leaves.iter().zip(pointed) {
let (session, draft, placeholder, commands, streaming, activity, current, sw, usage) =
point;
leaf.composer.update(cx, |composer, cx| {
composer.set_tools(!arranged, cx);
composer.set_session(session, &draft, cx);
composer.set_placeholder(&placeholder, cx);
composer.set_commands(&commands, cx);
composer.set_streaming(streaming, cx);
composer.set_activity(activity, cx);
composer.set_agents(&agents, current, cx);
composer.set_switches(&sw, 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 arranged = self.workspace.read(cx).active_space().is_some();
let body = match self.panes(window, cx) {
Some(panes) => panes,
None => match showing {
None => self.launch(window, cx),
Some(Pane::Chat) => {
self.conversation(self.workspace.read(cx).active_id(), None, window, cx)
}
Some(Pane::Board) => match self.workspace.read(cx).active.zip(
self.workspace
.read(cx)
.active_project()
.and_then(|open| open.board),
) {
Some((project, at)) => self.board(project, at, None, window, cx),
None => self.launch(window, cx),
},
Some(Pane::Article) => self
.workspace
.read(cx)
.active
.zip(
self.workspace
.read(cx)
.active_project()
.and_then(|open| open.article),
)
.and_then(|(project, at)| self.article(project, at, None, window, cx))
.unwrap_or_else(|| self.launch(window, cx)),
Some(Pane::Table) => self
.workspace
.read(cx)
.active
.zip(
self.workspace
.read(cx)
.active_project()
.and_then(|open| open.table),
)
.and_then(|(project, at)| self.table(project, at, None, cx))
.unwrap_or_else(|| self.launch(window, cx)),
},
};
let body = match arranged {
true => body,
false => self.lone_pane(body, cx),
};
let content = div()
.flex_1()
.min_h_0()
.flex()
.flex_col()
.overflow_hidden()
.when(!arranged, |el| el.pt(px(root::HEADER_HEIGHT)))
.child(body);
let footer_height = self
.workspace
.read(cx)
.active_session()
.map(|chat| chat.transcript.footer_height.clone());
let main = div()
.flex_1()
.min_h_0()
.min_w_0()
.relative()
.bg(root::content_bg(&theme))
.flex()
.flex_col()
.child(content)
.children((!arranged).then(|| self.pane_header(window, cx)))
.children(match showing == Some(Pane::Chat) {
true => self.selection_bar(cx),
false => None,
})
.when(
showing == Some(Pane::Chat) && !arranged,
|column| match live {
true => column
.on_drop(
cx.listener(|this, paths: &bezel::gpui::ExternalPaths, _, cx| {
this.leaf()
.composer
.update(cx, |composer, cx| composer.drop_paths(paths, cx));
}),
)
.child(footer(
div()
.flex()
.flex_col()
.gap(px(8.))
.children(self.plan(cx))
.children(self.permission(cx))
.child(self.leaf().composer.clone()),
footer_height.clone(),
)),
false => column.children(
self.adrift_strip(cx)
.map(|strip| footer(strip, footer_height.clone())),
),
},
);
let terminal = self
.terminal
.as_ref()
.filter(|(visible, _)| *visible)
.map(|(_, terminal)| terminal.clone());
let changes = self.changes.clone();
let available = f32::from(window.viewport_size().width)
- if self.sidebar_open {
self.sidebar_width
} else {
0.
};
let available = available.max(0.);
let beside = panel_beside(available);
let width = panel_width(self.changes_width, available);
let height = panel_height(
self.terminal_height,
f32::from(window.viewport_size().height),
);
div()
.id("session-panels")
.relative()
.flex_1()
.min_w_0()
.min_h_0()
.flex()
.flex_col()
.on_drag_move(
cx.listener(|this, event: &DragMoveEvent<TerminalResize>, _, cx| {
this.terminal_height = panel_height(
f32::from(event.bounds.bottom() - event.event.position.y),
f32::from(event.bounds.size.height),
);
cx.notify();
}),
)
.child(
div()
.id("session-detail")
.relative()
.flex_1()
.min_h_0()
.flex()
.flex_row()
.on_drag_move(cx.listener(
|this, event: &DragMoveEvent<ChangesResize>, _, cx| {
this.changes_width = Some(panel_width(
Some(f32::from(event.bounds.right() - event.event.position.x)),
f32::from(event.bounds.size.width),
));
this.save_panel_layout_settled(cx);
cx.notify();
},
))
.child(main)
.children(changes.clone().filter(|_| beside).map(|panel| {
div()
.relative()
.w(px(width))
.min_w_0()
.flex_none()
.child(panel)
}))
.children(
changes
.clone()
.filter(|_| !beside)
.map(|panel| div().absolute().inset_0().child(panel)),
)
.when(changes.is_some() && beside, |row| {
row.child(
crate::view::component::divider::divider(&theme, Axis::Horizontal)
.id("changes-split")
.absolute()
.top_0()
.right(px(width - crate::view::component::divider::HIT / 2.))
.on_drag(ChangesResize, |_, _, _, cx| cx.new(|_| Empty)),
)
}),
)
.children(
terminal
.clone()
.map(|terminal| div().h(px(height)).flex_none().child(terminal)),
)
.when(terminal.is_some(), |column| {
column.child(
crate::view::component::divider::divider(&theme, Axis::Vertical)
.id("terminal-split")
.absolute()
.left_0()
.bottom(px(height - crate::view::component::divider::HIT / 2.))
.on_drag(TerminalResize, |_, _, _, cx| cx.new(|_| Empty)),
)
})
}
}
pub(crate) fn footer(
inner: impl IntoElement,
height: Option<std::rc::Rc<std::cell::Cell<bezel::gpui::Pixels>>>,
) -> impl IntoElement {
div()
.absolute()
.bottom(px(root::COMPOSER_BOTTOM))
.left_0()
.right_0()
.flex()
.justify_center()
.child(
floating::layer("composer-band")
.w_full()
.max_w(px(root::COMPOSER_COLUMN))
.px(px(root::COMPOSER_MARGIN))
.relative()
.child(inner)
.child(
bezel::gpui::canvas(
move |bounds, window, _| {
if let Some(height) = &height
&& (height.replace(bounds.size.height) - bounds.size.height).abs()
> px(0.5)
{
window.refresh();
}
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
),
)
}
fn make_list(rows: impl IntoIterator<Item = AnyElement>) -> impl IntoElement {
div()
.mt(px(22.))
.flex()
.flex_col()
.items_stretch()
.gap(px(2.))
.children(rows)
}
impl Cydonia {
pub(crate) fn launch(&self, window: &Window, cx: &mut Context<Self>) -> AnyElement {
match self.workspace.read(cx).active_project().is_some() {
true => self.nothing_open(window, cx),
false => self.no_project(cx),
}
}
fn nothing_open(&self, window: &Window, 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 agents: Vec<(String, Option<Icon>)> = workspace
.settings
.agents
.iter()
.map(|entry| (entry.name.clone(), workspace.agent_icon(&entry.name)))
.collect();
let mut rows: Vec<AnyElement> = Vec::new();
if sessions && agents.len() > 1 {
let picks: Vec<_> = agents
.into_iter()
.enumerate()
.map(|(at, (name, icon))| {
let icon = icon.unwrap_or_else(|| icons::social::MessageCircle.into());
menu::row(
Item::action(name).with_icon(icon),
move |this, window, cx| this.pick_agent(at, window, cx),
)
})
.collect();
let trigger = self.make_row(
"session",
"New session",
icons::social::MessageCirclePlus,
keys::shortcut(&NewSession, window),
cx,
move |this, at, _, cx| this.toggle_menu_at(Menu::Launch, at, cx),
);
rows.push(
self.menu_press(trigger, Menu::Launch, cx)
.relative()
.children((self.menu == Some(Menu::Launch)).then(|| {
let card = self.menu_card("launch-menu", picks, cx);
match self.menu_point(&Menu::Launch) {
Some(point) => popover::menu_at("launch-menu", point, card, None),
None => popover::anchored_menu_below("launch-menu", card, None),
}
}))
.into_any_element(),
);
} else if sessions {
rows.push(
self.make_row(
"session",
"New session",
icons::social::MessageCirclePlus,
keys::shortcut(&NewSession, window),
cx,
move |this, _, window, cx| this.new_session_action(&NewSession, window, cx),
)
.into_any_element(),
);
}
if boards {
rows.push(
self.make_row(
"board",
"New board",
icons::development::SquareKanban,
keys::shortcut(&NewBoard, window),
cx,
move |this, _, window, cx| this.ask_new_board(ix, window, cx),
)
.into_any_element(),
);
}
rows.push(
self.make_row(
"article",
"New article",
icons::files::FilePlus,
keys::shortcut(&NewArticle, window),
cx,
move |this, _, window, cx| this.new_article(ix, window, cx),
)
.into_any_element(),
);
if tables {
rows.push(
self.make_row(
"table",
"New table",
icons::files::Table2,
keys::shortcut(&NewTable, window),
cx,
move |this, _, window, cx| this.new_table(ix, window, cx),
)
.into_any_element(),
);
}
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: impl Into<SharedString>,
label: impl Into<SharedString>,
glyph: impl Into<Icon>,
chord: Option<SharedString>,
cx: &mut Context<Self>,
make: impl Fn(&mut Self, Option<Point<Pixels>>, &mut Window, &mut Context<Self>) + 'static,
) -> Stateful<Div> {
let theme = Theme::of(cx).clone();
let (id, label) = (id.into(), label.into());
let fade = Fade::new(Painter::of(cx), format!("launch-{id}"));
popover::menu_row(&theme, false, Some(fade))
.id(id.clone())
.group(id.clone())
.child(
icons::icon(glyph)
.size(px(15.))
.flex_none()
.text_color(theme.text_muted)
.group_hover(id, |el| el.text_color(theme.text)),
)
.child(div().flex_1().child(label))
.children(chord.map(|chord| popover::kbd_hint(&theme, chord)))
.on_click(
cx.listener(move |this, click: &gpui::ClickEvent, window, cx| {
make(this, click.mouse_position(), window, cx)
}),
)
}
pub(crate) fn conversation(
&self,
on: Option<u64>,
entry: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let theme = Theme::of(cx).clone();
let workspace = self.workspace.read(cx);
let Some(chat) = on.and_then(|on| workspace.session(on)) else {
return match self.leaf_of(entry).asked_session {
true => self.no_agent(None, false, cx),
false => div().flex_1().into_any_element(),
};
};
if chat.unsaid() && chat.fork.is_none() && chat.queue.is_empty() {
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;
let available = (f32::from(window.viewport_size().width)
- if self.sidebar_open {
self.sidebar_width
} else {
0.
})
.max(0.);
let beside = self.changes.is_some() && panel_beside(available);
let column = available
- match beside {
true => panel_width(self.changes_width, available),
false => 0.,
};
let pane_width = column * self.width_share(entry, cx);
let root = cx.entity().downgrade();
let on = entry.cloned();
let queued = move |window: &mut Window, cx: &mut bezel::gpui::App| {
let on = on.clone();
root.update(cx, |root, cx| {
root.queue(id, on.as_ref(), window, cx)
.map(IntoElement::into_any_element)
})
.ok()
.flatten()
};
self.workspace
.update(cx, |workspace, cx| match workspace.session(id) {
Some(chat) => transcript::render(chat, pane_width, queued, 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 take_queued(
&mut self,
id: u64,
ix: usize,
expected: &str,
cx: &mut Context<Self>,
) -> Option<String> {
let mut text = None;
self.workspace.update(cx, |workspace, cx| {
if workspace.active_id() != Some(id) {
return;
}
workspace.with_session(id, cx, |chat| {
if chat.queue.get(ix).is_some_and(|queued| queued == expected) {
text = chat.queue.remove(ix);
}
});
});
text
}
fn queue(
&mut self,
id: u64,
on: Option<&Member>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<impl IntoElement + use<>> {
let theme = Theme::of(cx).clone();
let chat = self.workspace.read(cx).session(id)?;
let cwd = chat.cwd.clone();
let queue = chat.queue.clone();
let pane = on.cloned();
self.leaf_of_mut(pane.as_ref())
.queued_galleries
.retain(|(session, ix, text), _| *session == id && queue.get(*ix) == Some(text));
if queue.is_empty() {
return None;
}
Some(
div()
.flex_none()
.flex()
.flex_col()
.items_end()
.gap(px(6.))
.children(queue.iter().enumerate().map(|(ix, text)| {
let edit_text = text.clone();
let cancel_text = text.clone();
let (doc, images) = transcript::gallery::document(text);
let gallery = (!images.is_empty()).then(|| {
self.leaf_of_mut(pane.as_ref())
.queued_galleries
.entry((id, ix, text.clone()))
.or_insert_with(|| {
cx.new(|cx| transcript::gallery::Gallery::new(images, &cwd, cx))
})
.clone()
});
div()
.max_w(px(440.))
.when(gallery.is_some(), |row| row.w(px(440.)).max_w_full())
.flex()
.flex_col()
.items_end()
.gap(px(4.))
.child(
div()
.min_w_0()
.when(gallery.is_some(), |bubble| bubble.w_full())
.px(px(14.))
.py(px(9.))
.rounded(px(Theme::surface_radius()))
.bg(theme.surface_raised.opacity(0.6))
.text_style(TextStyle::Body)
.text_color(theme.text_muted)
.flex()
.flex_col()
.gap(px(8.))
.when(!doc.blocks.is_empty(), |bubble| {
bubble.child(markdown::render(
&doc,
Default::default(),
window,
cx,
))
})
.children(gallery),
)
.child(
div()
.flex_none()
.flex()
.gap(px(2.))
.text_style(TextStyle::Caption)
.text_color(theme.text_muted)
.child(
theme
.ghost(("edit-queued", ix))
.size(px(24.))
.flex()
.items_center()
.justify_center()
.child(
icons::icon(icons::text::Pencil)
.size(px(12.))
.text_color(theme.text_muted),
)
.tooltip(|window, cx| {
Tooltip::text("Edit queued message", window, cx)
})
.on_click({
let pane = pane.clone();
cx.listener(move |this, _, window, cx| {
let Some(text) =
this.take_queued(id, ix, &edit_text, cx)
else {
return;
};
this.leaf_of(pane.as_ref())
.composer
.clone()
.update(cx, |composer, cx| {
composer.restore_queued(text, window, cx);
});
})
}),
)
.child(
theme
.ghost(("cancel-queued", ix))
.size(px(24.))
.flex()
.items_center()
.justify_center()
.child(
icons::icon(icons::notifications::X)
.size(px(12.))
.text_color(theme.text_muted),
)
.tooltip(|window, cx| {
Tooltip::text("Cancel queued message", window, cx)
})
.on_click(cx.listener(move |this, _, _, cx| {
this.take_queued(id, ix, &cancel_text, cx);
})),
),
)
})),
)
}
}
#[cfg(test)]
#[path = "../../tests/unit/queued_images.rs"]
mod queued_image_tests;