use crate::{
model::{
session::ChatSession,
settings::Settings,
state::{self, State},
workspace::{Reloaded, Workspace},
},
view::{
board::{self, Editing},
component::{
composer::{Composer, ComposerEvent},
menu::Menu,
meter,
},
confirm, create, info,
settings::{self, Section, SettingsWindow},
sidebar::{Filter, Renaming, Row},
table,
},
};
use anyhow::Result;
use bezel::{
gpui::{
self, AnyElement, App, Axis, Bounds, Context, DragMoveEvent, Empty, Entity, FocusHandle,
Hsla, KeyBinding, PathPromptOptions, Render, TitlebarOptions, UniformListScrollHandle,
Window, WindowBounds, WindowHandle, WindowOptions, actions, div, point, prelude::*, px,
size,
},
motion::{Fade, Painter},
theme::{Material, TextStyle, Theme, Typeset, appearance},
ui::{
floating::Floating,
icons,
input::TextField,
menu::Cursor,
stats::Stats,
widgets::{ButtonStyle, Buttons, Content, Layout, SPLIT_HANDLE_HIT, SplitDrag, SplitStyle},
},
};
actions!(
cydonia,
[
NewSession,
NewBoard,
NewArticle,
NewTable,
OpenProject,
CloseProject,
OpenSettings,
ToggleSidebar,
ShowChat,
ShowBoard,
ShowArticle,
ShowTable,
CommitName,
DismissName,
NextEntry,
PrevEntry,
CopySelection
]
);
const RENAME_CONTEXT: &str = "CydoniaSessionName";
const SIDEBAR_WIDTH: f32 = 200.;
const SIDEBAR_WIDTH_MIN: f32 = 180.;
const SIDEBAR_WIDTH_MAX: f32 = 420.;
pub(crate) const SIDEBAR_GUTTER: f32 = 8.;
const SIDEBAR_MATERIAL: Material = Material::Thick;
const CONTENT_MATERIAL: Material = Material::UltraThick;
pub(crate) const HEADER_HEIGHT: f32 = 36.;
pub(crate) fn composer_height() -> f32 {
composer_disc() + 2. * COMPOSER_INSET
}
pub(crate) const COMPOSER_INSET: f32 = 6.;
pub(crate) fn composer_disc() -> f32 {
TextStyle::Body.painted_line_height()
}
pub(crate) const COMPOSER_BOTTOM: f32 = 20.;
pub(crate) fn sidebar_bg(theme: &Theme) -> Hsla {
material(theme, SIDEBAR_MATERIAL).unwrap_or(theme.surface)
}
pub(crate) fn content_bg(theme: &Theme) -> Hsla {
material(theme, CONTENT_MATERIAL).unwrap_or(theme.bg)
}
fn material(theme: &Theme, thickness: Material) -> Option<Hsla> {
theme.vibrancy.then(|| Hsla {
a: thickness.opacity(),
..theme.vibrancy_tint()
})
}
const TRAFFIC_LIGHT_SIZE: f32 = 14.;
pub const TRAFFIC_LIGHT_X: f32 = 12.;
pub const TRAFFIC_LIGHT_Y: f32 = (HEADER_HEIGHT - TRAFFIC_LIGHT_SIZE) / 2.;
const TRAFFIC_LIGHT_SPACING: f32 = 23.;
pub(crate) const HEADER_INSET: f32 = 16.;
pub(crate) const TOOLBAR_INSET: f32 = if cfg!(target_os = "macos") {
TRAFFIC_LIGHT_X + 2. * TRAFFIC_LIGHT_SPACING + TRAFFIC_LIGHT_SIZE + 6. + HEADER_INSET
} else {
HEADER_INSET
};
pub fn init(cx: &mut App) {
cx.bind_keys([
KeyBinding::new("cmd-n", NewSession, None),
KeyBinding::new("cmd-o", OpenProject, None),
KeyBinding::new("cmd-,", OpenSettings, None),
KeyBinding::new("cmd-b", ToggleSidebar, None),
KeyBinding::new("cmd-1", ShowChat, None),
KeyBinding::new("cmd-2", ShowBoard, None),
KeyBinding::new("cmd-3", ShowArticle, None),
KeyBinding::new("cmd-4", ShowTable, None),
KeyBinding::new("alt-cmd-right", NextEntry, None),
KeyBinding::new("alt-cmd-left", PrevEntry, None),
KeyBinding::new("ctrl-tab", NextEntry, None),
KeyBinding::new("ctrl-shift-tab", PrevEntry, None),
KeyBinding::new("cmd-c", CopySelection, None),
KeyBinding::new("enter", CommitName, Some(RENAME_CONTEXT)),
KeyBinding::new("escape", DismissName, Some(RENAME_CONTEXT)),
]);
}
pub fn open(settings: Settings, state: State, cx: &mut App) -> Result<WindowHandle<Cydonia>> {
let bounds = Bounds::centered(None, size(px(1100.), px(760.)), cx);
cx.open_window(
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: Some(TitlebarOptions {
appears_transparent: true,
traffic_light_position: Some(point(px(TRAFFIC_LIGHT_X), px(TRAFFIC_LIGHT_Y))),
..Default::default()
}),
window_background: Theme::of(cx).window_background_appearance(),
window_min_size: Some(size(px(600.), px(320.))),
app_id: Some("cydonia".into()),
..Default::default()
},
|window, cx| {
appearance::observe_window(window, cx).detach();
cx.new(|cx| Cydonia::new(settings, state, window, cx))
},
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Chat,
Board,
Article,
Table,
}
impl Pane {
fn of(kind: state::Kind) -> Self {
match kind {
state::Kind::Session => Self::Chat,
state::Kind::Board => Self::Board,
state::Kind::Article => Self::Article,
state::Kind::Table => Self::Table,
}
}
}
fn stepped(at: Option<usize>, len: usize, step: isize) -> Option<usize> {
if len == 0 {
return None;
}
let at = at.unwrap_or(0) as isize;
Some((at + step).rem_euclid(len as isize) as usize)
}
pub struct Cydonia {
pub(crate) workspace: Entity<Workspace>,
pub(crate) sidebar_open: bool,
pub(crate) sidebar_width: f32,
pub(crate) composer: Entity<Composer>,
settings_window: Option<WindowHandle<SettingsWindow>>,
pub(crate) pane: Pane,
pub(crate) editing: Option<Editing>,
pub(crate) card_field: Entity<TextField>,
pub(crate) cell: Option<table::Cell>,
pub(crate) cell_field: Entity<TextField>,
pub(crate) confirming: Option<confirm::Confirming>,
pub(crate) info: Option<info::BoardInfo>,
pub(crate) making: Option<create::Making>,
pub(crate) info_pressed: bool,
pub(crate) menu: Option<Menu>,
pub(crate) menu_cursor: Cursor,
pub(crate) menu_pressed: bool,
pub(crate) filter: Filter,
pub(crate) renaming: Option<Renaming>,
pub(crate) name_field: Entity<TextField>,
meter: Entity<Stats>,
meter_at: Floating,
pub(crate) rail: UniformListScrollHandle,
focus: FocusHandle,
}
impl Cydonia {
pub fn new(
settings: Settings,
state: State,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let composer = cx.new(Composer::new);
cx.subscribe(
&composer,
|this, _, event: &ComposerEvent, cx| match event {
ComposerEvent::Submit(text) => this.submit(text.clone(), cx),
ComposerEvent::Cancel => this.cancel_turn(cx),
ComposerEvent::Agent(ix) => this.pick_agent(*ix, cx),
ComposerEvent::Install => this.open_settings(Section::Agents, cx),
ComposerEvent::Switch(id, value) => this.switch(id, value, cx),
},
)
.detach();
let card_field = board::field(cx);
let cell_field = table::field(cx);
let name_field = cx.new(|cx| {
TextField::new(cx)
.with_frame(false)
.with_key_context(RENAME_CONTEXT)
.with_placeholder("name this session…")
});
let workspace = cx.new(|cx| Workspace::new(settings, state, cx));
cx.observe(&workspace, |this, _, cx| this.sync_composer(cx))
.detach();
cx.subscribe_in(&workspace, window, |this, _, _: &Reloaded, window, cx| {
this.drop_stale_edit(cx);
this.cell = None;
this.cell_field.update(cx, |field, cx| field.clear(cx));
this.follow_article(window, cx);
cx.notify();
})
.detach();
let mut this = Self {
meter: cx.new(Stats::new),
meter_at: Floating::new(Painter::of(cx)),
workspace,
sidebar_open: true,
sidebar_width: SIDEBAR_WIDTH,
composer,
settings_window: None,
pane: Pane::Chat,
editing: None,
card_field,
cell: None,
cell_field,
confirming: None,
info: None,
making: None,
info_pressed: false,
menu: None,
menu_cursor: Cursor::default(),
menu_pressed: false,
filter: Filter::default(),
renaming: None,
name_field,
rail: UniformListScrollHandle::new(),
focus: cx.focus_handle(),
};
cx.on_focus_lost(window, |this, window, cx| window.focus(&this.focus, cx))
.detach();
cx.observe_window_activation(window, |this, window, cx| {
if window.is_window_active() {
this.workspace
.update(cx, |workspace, cx| workspace.reload_projects(cx));
}
})
.detach();
this.land(cx);
this.sync_composer(cx);
let composer = this
.workspace
.read(cx)
.active_session()
.is_some_and(ChatSession::resumable)
.then(|| this.composer_focus_handle(cx));
window.focus(composer.as_ref().unwrap_or(&this.focus), cx);
this
}
pub(crate) fn new_session_action(
&mut self,
_: &NewSession,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.show_pane(Pane::Chat, cx);
self.workspace.update(cx, |workspace, cx| {
if let Some(entry) = workspace.preferred_agent() {
workspace.new_session(entry, None, cx);
}
});
}
fn copy_selection(&mut self, _: &CopySelection, _: &mut Window, cx: &mut Context<Self>) {
self.workspace
.update(cx, |workspace, cx| workspace.copy_selection(cx));
}
pub(crate) fn next_entry(
&mut self,
_: &NextEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.cycle_entry(1, window, cx);
}
pub(crate) fn prev_entry(
&mut self,
_: &PrevEntry,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.cycle_entry(-1, window, cx);
}
fn cycle_entry(&mut self, step: isize, window: &mut Window, cx: &mut Context<Self>) {
let Some(pane) = self.showing(cx) else {
return;
};
let workspace = self.workspace.read(cx);
let Some(project) = workspace.active else {
return;
};
let Some(open) = workspace.projects.get(project) else {
return;
};
let showing = match pane {
Pane::Chat => open.active.map(|id| Row::Session { project, id }),
Pane::Board => open.board.map(|ix| Row::Board { project, ix }),
Pane::Article => open.article.map(|ix| Row::Article { project, ix }),
Pane::Table => open.table.map(|ix| Row::Table { project, ix }),
};
let ring: Vec<Row> = self
.entries(project, cx)
.into_iter()
.filter(|row| !matches!(row, Row::Archive(_)))
.collect();
let at = showing.and_then(|row| ring.iter().position(|entry| *entry == row));
let Some(landing) = stepped(at, ring.len(), step).map(|ix| ring[ix]) else {
return;
};
self.open_row(landing, window, cx);
self.reveal(landing, cx);
}
pub(crate) fn select_project(&mut self, ix: usize, cx: &mut Context<Self>) {
self.commit(cx);
self.workspace
.update(cx, |workspace, cx| workspace.select_project(ix, cx));
self.land(cx);
}
fn land(&mut self, cx: &mut Context<Self>) {
if let Some(kind) = self.workspace.read(cx).landing() {
self.pane = Pane::of(kind);
}
}
pub(crate) fn close_project(&mut self, ix: usize, cx: &mut Context<Self>) {
self.commit(cx);
self.workspace
.update(cx, |workspace, cx| workspace.close_project(ix, cx));
}
pub(crate) fn select_session(&mut self, id: u64, cx: &mut Context<Self>) {
self.show_pane(Pane::Chat, cx);
self.workspace
.update(cx, |workspace, cx| workspace.select_session(id, cx));
}
pub(crate) fn open_settings_action(
&mut self,
_: &OpenSettings,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.open_settings(Section::General, cx);
}
pub(crate) fn toggle_sidebar(&mut self, cx: &mut Context<Self>) {
self.sidebar_open = !self.sidebar_open;
cx.notify();
}
pub(crate) fn toggle_sidebar_action(
&mut self,
_: &ToggleSidebar,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.toggle_sidebar(cx);
}
pub(crate) fn close_project_action(
&mut self,
_: &CloseProject,
_: &mut Window,
cx: &mut Context<Self>,
) {
let Some(ix) = self.workspace.read(cx).active else {
return;
};
self.close_project(ix, cx);
}
pub(crate) fn show_chat(&mut self, _: &ShowChat, _: &mut Window, cx: &mut Context<Self>) {
self.show_pane(Pane::Chat, cx);
}
pub(crate) fn show_board(&mut self, _: &ShowBoard, _: &mut Window, cx: &mut Context<Self>) {
self.show_pane(Pane::Board, cx);
}
pub(crate) fn show_article(&mut self, _: &ShowArticle, _: &mut Window, cx: &mut Context<Self>) {
self.show_pane(Pane::Article, cx);
}
pub(crate) fn show_table(&mut self, _: &ShowTable, _: &mut Window, cx: &mut Context<Self>) {
self.show_pane(Pane::Table, cx);
}
pub(crate) fn open_settings(&mut self, section: Section, cx: &mut Context<Self>) {
let workspace = self.workspace.clone();
self.settings_window = settings::open(workspace, self.settings_window, section, cx);
}
pub(crate) fn open_project_action(
&mut self,
_: &OpenProject,
_: &mut Window,
cx: &mut Context<Self>,
) {
let picked = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: None,
});
cx.spawn(async move |this, cx| {
let Ok(Ok(Some(paths))) = picked.await else {
return;
};
let Some(path) = paths.into_iter().next() else {
return;
};
let _ = this.update(cx, |this, cx| {
this.workspace
.update(cx, |workspace, cx| workspace.open_project(path, cx));
});
})
.detach();
}
pub(crate) fn showing(&self, cx: &App) -> Option<Pane> {
if self.has_pane(self.pane, cx) {
return Some(self.pane);
}
[Pane::Chat, Pane::Board, Pane::Article, Pane::Table]
.into_iter()
.find(|&pane| self.has_pane(pane, cx))
}
pub(crate) fn has_pane(&self, pane: Pane, cx: &App) -> bool {
let workspace = self.workspace.read(cx);
match pane {
Pane::Chat => workspace.active_session().is_some(),
Pane::Board => workspace.active_board().is_some(),
Pane::Article => workspace.active_article().is_some(),
Pane::Table => workspace.active_table().is_some(),
}
}
pub(crate) fn no_project(&self, cx: &mut Context<Self>) -> AnyElement {
let theme = Theme::of(cx).clone();
let painter = Painter::of(cx);
theme
.empty_state(
icons::files::Folder,
"No project open",
"An agent runs in a directory. Pick one to start.",
)
.flex_1()
.child(
theme
.button(
"Open folder…",
ButtonStyle::Prominent,
Some(Fade::new(painter, "open-project-empty")),
)
.id("open-project-empty")
.on_click(cx.listener(|this, _, window, cx| {
this.open_project_action(&OpenProject, window, cx);
})),
)
.into_any_element()
}
}
impl Render for Cydonia {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::of(cx).clone();
div()
.size_full()
.relative()
.flex()
.flex_row()
.font_family(theme.font_sans.clone())
.text_color(theme.text)
.text_style(TextStyle::Body)
.on_action(cx.listener(Self::copy_selection))
.on_action(cx.listener(Self::commit_cell_action))
.on_action(cx.listener(Self::dismiss_cell))
.on_action(cx.listener(Self::commit_name))
.on_action(cx.listener(Self::commit_info))
.on_action(cx.listener(Self::dismiss_info))
.on_action(cx.listener(Self::make_board))
.on_action(cx.listener(Self::dismiss_new_board))
.on_action(cx.listener(Self::dismiss_name))
.map(|root| self.commands(root, cx))
.on_drag_move(
cx.listener(|this, event: &DragMoveEvent<SplitDrag>, _, cx| {
this.sidebar_width = f32::from(event.event.position.x)
.clamp(SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX);
cx.notify();
}),
)
.child(div().track_focus(&self.focus))
.when(self.sidebar_open, |root| root.child(self.sidebar(cx)))
.child(self.detail(window, cx))
.when(self.sidebar_open, |root| {
root.child(
theme
.split_handle(Axis::Horizontal, SplitStyle::Ghost)
.id("sidebar-split")
.absolute()
.top_0()
.left(px(self.sidebar_width - SPLIT_HANDLE_HIT / 2.))
.on_drag(SplitDrag, |_, _, _, cx| cx.new(|_| Empty)),
)
})
.children(
self.workspace
.read(cx)
.meter
.then(|| meter::panel("app-meter", &self.meter_at, &self.meter, window)),
)
.children(self.confirm_delete(cx))
.children(self.new_board_dialog(cx))
}
}